mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-26 14:45:33 -04:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 537f7180e6 | |||
| de12d4734a | |||
| 5d23495eb2 | |||
| 22379fe736 | |||
| 4e46e415ea | |||
| 6a2a39f892 | |||
| 5ce2056521 | |||
| e0ccf250a4 | |||
| 72c0bde8a9 | |||
| 2e16394b41 | |||
| 060dbf0681 | |||
| d9ad418195 | |||
| 08994a0a96 | |||
| e9136f801a | |||
| e90dbc1012 | |||
| d47715036a | |||
| 87407b3a09 | |||
| 119228a6db | |||
| 8f5e36a079 | |||
| 30dd789351 | |||
| e8175c9535 | |||
| bd9149f79a | |||
| fef08ed114 | |||
| 7e5db9a3c6 | |||
| 2f246c7779 | |||
| 8ec27fd903 | |||
| b57989f08c | |||
| 91bba117c1 | |||
| 4c82e4a172 | |||
| b899095f18 | |||
| 888e25624f | |||
| c062c27648 | |||
| 93ec7cbb52 | |||
| c12b8ab6c9 | |||
| e812a29233 | |||
| ca4973c41f | |||
| 91b4171b3f | |||
| d36879bd50 | |||
| b51656770d | |||
| 5f63a3d3bd | |||
| bd0c67b6d3 | |||
| ff5bcd9864 |
@@ -86,6 +86,7 @@ Bundled in `static/fonts/`:
|
|||||||
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
|
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
|
||||||
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
|
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
|
||||||
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
|
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
|
||||||
|
| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez |
|
||||||
|
|
||||||
## Python dependencies
|
## Python dependencies
|
||||||
|
|
||||||
|
|||||||
+30
@@ -1,3 +1,14 @@
|
|||||||
|
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
||||||
|
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
||||||
|
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
|
||||||
|
# the final image / Cookbook never has to compile the broken sdists. See
|
||||||
|
# docker/build-realesrgan-wheels.sh for the full rationale.
|
||||||
|
FROM python:3.14-slim AS realesrgan-wheels
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
||||||
|
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
||||||
|
|
||||||
FROM python:3.14-slim
|
FROM python:3.14-slim
|
||||||
|
|
||||||
# System deps. tmux is required by Cookbook for background downloads/serves.
|
# System deps. tmux is required by Cookbook for background downloads/serves.
|
||||||
@@ -18,8 +29,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
tmux \
|
tmux \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
gosu \
|
gosu \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0t64 \
|
||||||
|
libxcb1 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1,
|
||||||
|
# libglib-2.0/libgthread, libxcb.so.1) that opencv-python (cv2) loads. The
|
||||||
|
# slim base omits them, so the Cookbook "install realesrgan" path imports cv2
|
||||||
|
# and dies with `libxcb.so.1: cannot open shared object file` despite a clean
|
||||||
|
# pip install. Using full opencv-python (not -headless) because basicsr/gfpgan/
|
||||||
|
# facexlib/realesrgan all depend on the `opencv-python` distribution by name.
|
||||||
|
|
||||||
# Docker CLI (client only — daemon stays on the host via the
|
# Docker CLI (client only — daemon stays on the host via the
|
||||||
# /var/run/docker.sock mount). The Debian `docker.io` package ships
|
# /var/run/docker.sock mount). The Debian `docker.io` package ships
|
||||||
# dockerd but not the client binary on slim, so grab the static client
|
# dockerd but not the client binary on slim, so grab the static client
|
||||||
@@ -46,6 +67,15 @@ COPY requirements.txt requirements-optional.txt ./
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt \
|
RUN pip install --no-cache-dir -r requirements.txt \
|
||||||
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
|
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
|
||||||
|
|
||||||
|
# Pre-install the patched basicsr/gfpgan/facexlib wheels built in the
|
||||||
|
# realesrgan-wheels stage (--no-deps keeps the image lean — torch & friends are
|
||||||
|
# pulled only when realesrgan is actually installed). With these dists already
|
||||||
|
# satisfied, the Cookbook's plain `pip install realesrgan` resolves them from
|
||||||
|
# wheels instead of rebuilding the sdists that fail on Python 3.14.
|
||||||
|
COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/
|
||||||
|
RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
|
||||||
|
&& rm -rf /tmp/odysseus-wheels
|
||||||
|
|
||||||
# Copy app code
|
# Copy app code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop.
|
||||||
|
# When started via `python -m uvicorn` from a terminal, uvicorn sets this
|
||||||
|
# automatically. But the VS Code debugger (and other non-uvicorn entrypoints)
|
||||||
|
# use the default SelectorEventLoop, which raises NotImplementedError on any
|
||||||
|
# subprocess call. Force ProactorEventLoop here so the right loop is always
|
||||||
|
# used, regardless of how the process is launched.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||||
|
|
||||||
|
|
||||||
def register_static_mime_types() -> None:
|
def register_static_mime_types() -> None:
|
||||||
@@ -44,7 +54,7 @@ from typing import Dict
|
|||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
|
from fastapi.responses import JSONResponse, FileResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
@@ -65,7 +75,7 @@ from core.exceptions import (
|
|||||||
|
|
||||||
import bcrypt as _bcrypt
|
import bcrypt as _bcrypt
|
||||||
|
|
||||||
from src.app_helpers import abs_join
|
from src.app_helpers import abs_join, serve_html_with_nonce
|
||||||
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
@@ -791,23 +801,17 @@ app.include_router(setup_companion_routes())
|
|||||||
|
|
||||||
# ========= ROUTES (kept in app.py) =========
|
# ========= ROUTES (kept in app.py) =========
|
||||||
|
|
||||||
def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
|
|
||||||
"""Read an HTML file and inject the CSP nonce into inline <script> tags."""
|
|
||||||
with open(file_path, "r", encoding="utf-8") as f:
|
|
||||||
html = f.read()
|
|
||||||
nonce = getattr(request.state, "csp_nonce", "")
|
|
||||||
html = html.replace("{{CSP_NONCE}}", nonce)
|
|
||||||
return HTMLResponse(html)
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def serve_index(request: Request):
|
async def serve_index(request: Request):
|
||||||
static_path = abs_join(BASE_DIR, "static/index.html")
|
static_path = abs_join(BASE_DIR, "static/index.html")
|
||||||
if os.path.exists(static_path):
|
if os.path.exists(static_path):
|
||||||
return _serve_html_with_nonce(request, static_path)
|
return serve_html_with_nonce(request, static_path)
|
||||||
root_path = abs_join(BASE_DIR, "index.html")
|
# No static bundle — fall back to a root-level index.html if one is shipped.
|
||||||
if os.path.exists(root_path):
|
# If neither exists, serve_html_with_nonce logs it and returns a generic 500:
|
||||||
return _serve_html_with_nonce(request, root_path)
|
# a missing index.html is a broken deployment (server fault), not a client
|
||||||
raise HTTPException(404, "index.html not found")
|
# "not found". This keeps the app-shell route consistent with the other
|
||||||
|
# bundled-template routes instead of mislabelling the fault as a 404.
|
||||||
|
return serve_html_with_nonce(request, abs_join(BASE_DIR, "index.html"))
|
||||||
|
|
||||||
@app.get("/notes")
|
@app.get("/notes")
|
||||||
async def serve_notes(request: Request):
|
async def serve_notes(request: Request):
|
||||||
@@ -848,13 +852,13 @@ async def serve_library(request: Request):
|
|||||||
@app.get("/backgrounds")
|
@app.get("/backgrounds")
|
||||||
async def serve_backgrounds(request: Request):
|
async def serve_backgrounds(request: Request):
|
||||||
"""Sandbox page for prototyping background effects. No auth required."""
|
"""Sandbox page for prototyping background effects. No auth required."""
|
||||||
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
|
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
|
||||||
|
|
||||||
@app.get("/login")
|
@app.get("/login")
|
||||||
async def serve_login(request: Request):
|
async def serve_login(request: Request):
|
||||||
if not AUTH_ENABLED:
|
if not AUTH_ENABLED:
|
||||||
return RedirectResponse(url="/", status_code=302)
|
return RedirectResponse(url="/", status_code=302)
|
||||||
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
|
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
|
||||||
|
|
||||||
@app.get("/api/version")
|
@app.get("/api/version")
|
||||||
async def get_version():
|
async def get_version():
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# src/exceptions.py
|
# core/exceptions.py
|
||||||
"""Custom exceptions for the application."""
|
"""Custom exceptions for the application."""
|
||||||
|
|
||||||
class SessionNotFoundError(Exception):
|
class SessionNotFoundError(Exception):
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Helpers for keeping sensitive data out of logs.
|
||||||
|
|
||||||
|
Endpoint URLs configured by admins can embed credentials in the userinfo
|
||||||
|
(``https://user:pass@host``) or query string (``?api_key=...``). Logging them
|
||||||
|
raw leaks those secrets, so route/diagnostic logs run URLs through
|
||||||
|
``redact_url`` first. Reconstructing the URL without userinfo/query/fragment
|
||||||
|
also doubles as a sanitizer barrier for CodeQL's clear-text-logging query.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
|
|
||||||
|
def redact_url(url: str) -> str:
|
||||||
|
"""Return a URL safe for logs by removing userinfo and query/fragment.
|
||||||
|
|
||||||
|
Keeps scheme, host, port and path so logs stay useful for debugging.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url or "")
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if ":" in host: # IPv6 literal — re-bracket so host:port stays unambiguous
|
||||||
|
host = f"[{host}]"
|
||||||
|
if parsed.port:
|
||||||
|
host = f"{host}:{parsed.port}"
|
||||||
|
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
|
||||||
|
except Exception:
|
||||||
|
return "<endpoint>"
|
||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build patched wheels for Real-ESRGAN's unmaintained dependencies.
|
||||||
|
#
|
||||||
|
# basicsr / gfpgan / facexlib (xinntao, last released 2022) read their version
|
||||||
|
# in setup.py with:
|
||||||
|
#
|
||||||
|
# exec(compile(f.read(), version_file, 'exec'))
|
||||||
|
# return locals()['__version__']
|
||||||
|
#
|
||||||
|
# Python 3.13+ implements PEP 667: locals() inside a function returns an
|
||||||
|
# independent snapshot that exec() can no longer mutate, so the read raises
|
||||||
|
# `KeyError: '__version__'` and the sdist build fails. That is why the Cookbook
|
||||||
|
# "install realesrgan" button dies on the python:3.14 image. The packages have
|
||||||
|
# no fixed release, so we patch get_version() to exec into an explicit namespace
|
||||||
|
# dict (works on every Python) and build wheels from the patched source.
|
||||||
|
#
|
||||||
|
# Usage: build-realesrgan-wheels.sh [OUTPUT_DIR] (default: /wheels)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OUT="${1:-/wheels}"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
cd "$work"
|
||||||
|
|
||||||
|
# Pinned to the versions Real-ESRGAN 0.3.0 resolves to.
|
||||||
|
SPECS="basicsr==1.4.2 gfpgan==1.3.8 facexlib==0.3.0"
|
||||||
|
|
||||||
|
for spec in $SPECS; do
|
||||||
|
name="${spec%%==*}"
|
||||||
|
ver="${spec##*==}"
|
||||||
|
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||||
|
# sdist URL from the PyPI JSON API instead.
|
||||||
|
url="$(python - "$name" "$ver" <<'PY'
|
||||||
|
import json, sys, urllib.request
|
||||||
|
name, ver = sys.argv[1], sys.argv[2]
|
||||||
|
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
||||||
|
for f in data["urls"]:
|
||||||
|
if f["packagetype"] == "sdist":
|
||||||
|
print(f["url"]); break
|
||||||
|
else:
|
||||||
|
sys.exit(f"no sdist found for {name}=={ver}")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
echo ">> fetching ${name} ${ver}: ${url}"
|
||||||
|
curl -fsSL "$url" -o "${name}.tar.gz"
|
||||||
|
tar xzf "${name}.tar.gz"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ">> patching get_version()"
|
||||||
|
python - <<'PY'
|
||||||
|
import pathlib
|
||||||
|
old_exec = "exec(compile(f.read(), version_file, 'exec'))"
|
||||||
|
new_exec = "_ver_ns = {}\n exec(compile(f.read(), version_file, 'exec'), _ver_ns)"
|
||||||
|
old_ret = "return locals()['__version__']"
|
||||||
|
new_ret = "return _ver_ns['__version__']"
|
||||||
|
patched = 0
|
||||||
|
for setup in pathlib.Path(".").glob("*/setup.py"):
|
||||||
|
s = setup.read_text()
|
||||||
|
if old_exec in s and old_ret in s:
|
||||||
|
setup.write_text(s.replace(old_exec, new_exec).replace(old_ret, new_ret))
|
||||||
|
print(" patched", setup)
|
||||||
|
patched += 1
|
||||||
|
assert patched == 3, f"expected to patch 3 setup.py files, patched {patched}"
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo ">> building wheels into ${OUT}"
|
||||||
|
pip wheel --no-deps -w "$OUT" ./basicsr-* ./gfpgan-* ./facexlib-*
|
||||||
|
ls -l "$OUT"
|
||||||
+14
-1
@@ -15,7 +15,7 @@ On first setup, Odysseus creates an admin account (`admin` unless
|
|||||||
For Docker installs, the same line is in `docker compose logs odysseus`.
|
For Docker installs, the same line is in `docker compose logs odysseus`.
|
||||||
Use that for the first login, then change it in **Settings**.
|
Use that for the first login, then change it in **Settings**.
|
||||||
|
|
||||||
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
|
Contributing? See [CONTRIBUTING.md](../CONTRIBUTING.md) for setup, testing, and
|
||||||
pull request guidelines.
|
pull request guidelines.
|
||||||
|
|
||||||
### Docker (recommended)
|
### Docker (recommended)
|
||||||
@@ -250,6 +250,19 @@ python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
|||||||
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
||||||
3.11+ version) for the venv step.
|
3.11+ version) for the venv step.
|
||||||
|
|
||||||
|
**Exposing on a LAN/Tailscale (Windows):** the launcher binds to `127.0.0.1` and
|
||||||
|
does **not** read `APP_BIND` / `ODYSSEUS_HOST` from `.env`, so editing `.env`
|
||||||
|
alone leaves the native Windows server on loopback. Pass the launcher's
|
||||||
|
`-BindHost` flag instead:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1 -BindHost 0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The manual `uvicorn` command takes the same address as `--host 0.0.0.0`. Bind
|
||||||
|
outside loopback only for a trusted LAN/VPN such as Tailscale: keep
|
||||||
|
`AUTH_ENABLED=true` and do not expose the port directly to the public internet.
|
||||||
|
|
||||||
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
||||||
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
||||||
model downloads and the agent shell tool, also install
|
model downloads and the agent shell tool, also install
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
|
||||||
|
with Reserved Font Name OpenDyslexic.
|
||||||
|
Copyright (c) 12/2012 - 2019
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -29,6 +29,7 @@ from routes.document_helpers import _owner_session_filter
|
|||||||
from core.database import SessionLocal, get_session_mode, set_session_mode
|
from core.database import SessionLocal, get_session_mode, set_session_mode
|
||||||
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
||||||
from core.database import Document as DBDocument, ModelEndpoint
|
from core.database import Document as DBDocument, ModelEndpoint
|
||||||
|
from core.log_safety import redact_url
|
||||||
from routes.research_routes import _resolve_research_endpoint
|
from routes.research_routes import _resolve_research_endpoint
|
||||||
from routes.model_routes import _visible_models
|
from routes.model_routes import _visible_models
|
||||||
from routes.chat_helpers import (
|
from routes.chat_helpers import (
|
||||||
@@ -930,7 +931,7 @@ def setup_chat_routes(
|
|||||||
if effective_do_research:
|
if effective_do_research:
|
||||||
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
||||||
_auth_keys = list(_r_headers.keys()) if _r_headers else []
|
_auth_keys = list(_r_headers.keys()) if _r_headers else []
|
||||||
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={_r_ep}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
|
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={redact_url(_r_ep)}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
|
||||||
|
|
||||||
# Clarification round: only for very short/vague queries on first research message.
|
# Clarification round: only for very short/vague queries on first research message.
|
||||||
# Skip in compare mode — each pane is a fresh session, so every one would
|
# Skip in compare mode — each pane is a fresh session, so every one would
|
||||||
@@ -1309,6 +1310,8 @@ def setup_chat_routes(
|
|||||||
"doc_stream_open", "doc_stream_delta",
|
"doc_stream_open", "doc_stream_delta",
|
||||||
"doc_update", "doc_suggestions", "ui_control",
|
"doc_update", "doc_suggestions", "ui_control",
|
||||||
"rounds_exhausted",
|
"rounds_exhausted",
|
||||||
|
"loop_breaker_triggered",
|
||||||
|
"intent_nudge_exhausted",
|
||||||
"ask_user",
|
"ask_user",
|
||||||
"plan_update",
|
"plan_update",
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import urljoin, urlparse, urlunparse
|
from urllib.parse import urljoin, urlparse, urlunparse
|
||||||
|
|
||||||
|
from core.log_safety import redact_url
|
||||||
from fastapi import APIRouter, Query, Depends, Response, HTTPException
|
from fastapi import APIRouter, Query, Depends, Response, HTTPException
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
@@ -689,15 +690,24 @@ def _delete_contact(uid: str) -> bool:
|
|||||||
url = _resolve_resource_url(uid)
|
url = _resolve_resource_url(uid)
|
||||||
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
||||||
r = httpx.delete(url, auth=auth, timeout=10)
|
r = httpx.delete(url, auth=auth, timeout=10)
|
||||||
if r.status_code in (200, 204):
|
if r.status_code in (200, 204, 404):
|
||||||
|
# Invalidate cache so the next fetch sees the server truth.
|
||||||
_contact_cache["fetched_at"] = None
|
_contact_cache["fetched_at"] = None
|
||||||
return True
|
# Verify: force a fresh fetch and check the UID is actually gone.
|
||||||
|
# A 404 on the guessed URL ({uid}.vcf) can mean the contact
|
||||||
|
# lives at a different resource URL — the DELETE missed it but
|
||||||
|
# we'd silently report success. This check catches that.
|
||||||
|
fresh = _fetch_contacts(force=True)
|
||||||
|
still_there = any(c.get("uid") == uid for c in fresh)
|
||||||
|
if still_there:
|
||||||
|
logger.warning(
|
||||||
|
f"CardDAV DELETE reported success for {uid} "
|
||||||
|
f"but UID still present after re-fetch — "
|
||||||
|
f"resource URL may differ from {redact_url(url)}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
if r.status_code == 404:
|
if r.status_code == 404:
|
||||||
# Resource not found at the resolved URL. With href resolution
|
logger.info(f"CardDAV DELETE 404 for {uid} — already gone")
|
||||||
# this should be rare (genuinely already deleted). Invalidate
|
|
||||||
# the cache and report success so the UI doesn't keep a ghost.
|
|
||||||
logger.info(f"CardDAV DELETE 404 for {uid} — treating as already gone")
|
|
||||||
_contact_cache["fetched_at"] = None
|
|
||||||
return True
|
return True
|
||||||
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
|
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from core.database import Document, DocumentVersion
|
from core.database import Document, DocumentVersion
|
||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
|
from src.auth_helpers import _auth_disabled
|
||||||
from src.upload_handler import UploadHandler
|
from src.upload_handler import UploadHandler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -78,6 +79,8 @@ def _verify_doc_owner(db, doc: Document, user: str):
|
|||||||
the session join for any not-yet-backfilled legacy row.
|
the session join for any not-yet-backfilled legacy row.
|
||||||
"""
|
"""
|
||||||
if user is None:
|
if user is None:
|
||||||
|
if _auth_disabled():
|
||||||
|
return # Single-user / no-auth mode: allow access
|
||||||
raise HTTPException(403, "Authentication required")
|
raise HTTPException(403, "Authentication required")
|
||||||
if doc.owner is not None:
|
if doc.owner is not None:
|
||||||
if doc.owner != user:
|
if doc.owner != user:
|
||||||
@@ -104,7 +107,6 @@ def _owner_session_filter(q, user):
|
|||||||
by the time this filter is live there are no NULL-owner rows to leak;
|
by the time this filter is live there are no NULL-owner rows to leak;
|
||||||
we therefore match the owner strictly for authenticated callers."""
|
we therefore match the owner strictly for authenticated callers."""
|
||||||
if not user:
|
if not user:
|
||||||
from src.auth_helpers import _auth_disabled
|
|
||||||
if user == "" or _auth_disabled():
|
if user == "" or _auth_disabled():
|
||||||
return q
|
return q
|
||||||
return q.filter(False)
|
return q.filter(False)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File,
|
|||||||
from sqlalchemy import case, func, or_
|
from sqlalchemy import case, func, or_
|
||||||
from core.database import SessionLocal, Document, DocumentVersion
|
from core.database import SessionLocal, Document, DocumentVersion
|
||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user, _auth_disabled
|
||||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -388,6 +388,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
if not user:
|
if not user:
|
||||||
|
if not _auth_disabled():
|
||||||
raise HTTPException(403, "Authentication required")
|
raise HTTPException(403, "Authentication required")
|
||||||
# v2 review HIGH-9: raise 403 explicitly when the caller
|
# v2 review HIGH-9: raise 403 explicitly when the caller
|
||||||
# can't see this session, instead of returning [] which the
|
# can't see this session, instead of returning [] which the
|
||||||
|
|||||||
+16
-2
@@ -44,6 +44,17 @@ from routes.email_helpers import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Recovers a `[{"action": ...}, ...]` JSON array from raw LLM output when the
|
||||||
|
# fenced-block strip leaves nothing usable. Runs on model output influenced by
|
||||||
|
# untrusted email bodies, so it must not backtrack: the object content class is
|
||||||
|
# `[^{}]` (brace-delimited, greedy) rather than the old `[^[\]]*?` lazy runs,
|
||||||
|
# which exploded exponentially on inputs like `[{"action"},{` + `}},{{` * N
|
||||||
|
# (CodeQL py/redos #198).
|
||||||
|
_CAL_ACTION_ARRAY_RE = re.compile(
|
||||||
|
r'\[\s*\{[^{}]*"action"[^{}]*\}\s*(?:,\s*\{[^{}]*\}\s*)*\]',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _owner_for_email_account(account_id: str | None) -> str:
|
def _owner_for_email_account(account_id: str | None) -> str:
|
||||||
if not account_id:
|
if not account_id:
|
||||||
@@ -558,7 +569,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
cal_extract = _strip_think(_raw_original)
|
cal_extract = _strip_think(_raw_original)
|
||||||
cal_extract = re.sub(r"^```(?:json)?\s*|\s*```$", "", cal_extract, flags=re.MULTILINE).strip()
|
cal_extract = re.sub(r"^```(?:json)?\s*|\s*```$", "", cal_extract, flags=re.MULTILINE).strip()
|
||||||
if not cal_extract and _raw_original:
|
if not cal_extract and _raw_original:
|
||||||
matches = list(re.finditer(r'\[\s*\{[^[\]]*?"action"[^[\]]*?\}\s*(?:,\s*\{[^[\]]*?\}\s*)*\]', _raw_original, re.DOTALL))
|
matches = list(_CAL_ACTION_ARRAY_RE.finditer(_raw_original))
|
||||||
if matches:
|
if matches:
|
||||||
cal_extract = matches[-1].group()
|
cal_extract = matches[-1].group()
|
||||||
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
|
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
|
||||||
@@ -683,7 +694,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
logger.warning(f"[cal-extract] JSON parse failed: {je} on raw={cal_extract[:200]!r}")
|
logger.warning(f"[cal-extract] JSON parse failed: {je} on raw={cal_extract[:200]!r}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[cal-extract] Meeting extraction LLM call failed for uid={uid}: {e}")
|
logger.warning(f"[cal-extract] Meeting extraction LLM call failed for uid={uid}: {e}")
|
||||||
# Record we processed this email so we don't re-LLM next run
|
else:
|
||||||
|
# Record we processed this email so we don't re-LLM next run.
|
||||||
|
# Only mark as processed on success ? transient LLM failures
|
||||||
|
# are retried on the next poll run (matches summary/reply pattern).
|
||||||
try:
|
try:
|
||||||
_cc = _sql3.connect(SCHEDULED_DB)
|
_cc = _sql3.connect(SCHEDULED_DB)
|
||||||
_cc.execute(
|
_cc.execute(
|
||||||
|
|||||||
+49
-18
@@ -17,6 +17,7 @@ from fastapi import APIRouter, HTTPException, Form, Query, Body, Request, Respon
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
||||||
|
from core.log_safety import redact_url as _redact_url_for_log
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
|
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
|
||||||
from src.tls_overrides import llm_verify
|
from src.tls_overrides import llm_verify
|
||||||
@@ -522,6 +523,10 @@ _NON_CHAT_EXACT_PREFIXES = (
|
|||||||
|
|
||||||
def _is_chat_model(model_id: str) -> bool:
|
def _is_chat_model(model_id: str) -> bool:
|
||||||
"""Return True if the model ID looks like a chat/completions-capable model."""
|
"""Return True if the model ID looks like a chat/completions-capable model."""
|
||||||
|
if not isinstance(model_id, str):
|
||||||
|
# Non-compliant upstreams can return non-string IDs (e.g. int/None);
|
||||||
|
# treat them as chat-capable rather than crashing on .lower().
|
||||||
|
return True
|
||||||
mid = model_id.lower()
|
mid = model_id.lower()
|
||||||
for prefix in _NON_CHAT_PREFIXES:
|
for prefix in _NON_CHAT_PREFIXES:
|
||||||
if mid.startswith(prefix):
|
if mid.startswith(prefix):
|
||||||
@@ -582,18 +587,6 @@ def _safe_build_headers(api_key: Optional[str], base_url: str) -> dict:
|
|||||||
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||||
|
|
||||||
|
|
||||||
def _redact_url_for_log(url: str) -> str:
|
|
||||||
"""Return a URL safe for logs by removing userinfo and query/fragment."""
|
|
||||||
try:
|
|
||||||
parsed = urlparse(url or "")
|
|
||||||
host = parsed.hostname or ""
|
|
||||||
if parsed.port:
|
|
||||||
host = f"{host}:{parsed.port}"
|
|
||||||
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
|
|
||||||
except Exception:
|
|
||||||
return "<endpoint>"
|
|
||||||
|
|
||||||
|
|
||||||
def _is_discovery_only_provider(provider: str) -> bool:
|
def _is_discovery_only_provider(provider: str) -> bool:
|
||||||
return provider == "chatgpt-subscription"
|
return provider == "chatgpt-subscription"
|
||||||
|
|
||||||
@@ -737,6 +730,34 @@ def _is_loading_model_response(resp: Any) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_model_ids(data: Any) -> List[str]:
|
||||||
|
"""Extract OpenAI-style model IDs (``{"data": [{"id": ...}]}``).
|
||||||
|
|
||||||
|
Tolerates a non-dict body and non-string IDs from non-compliant upstreams,
|
||||||
|
returning only non-empty string IDs.
|
||||||
|
"""
|
||||||
|
items = data.get("data") if isinstance(data, dict) else None
|
||||||
|
return [m["id"] for m in (items or [])
|
||||||
|
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_model_names(data: Any) -> List[str]:
|
||||||
|
"""Extract native-Ollama model names (``{"models": [{"name"|"model": ...}]}``).
|
||||||
|
|
||||||
|
Same tolerance as :func:`_openai_model_ids`: a non-dict body or non-string
|
||||||
|
value is skipped rather than crashing, preserving name-then-model precedence.
|
||||||
|
"""
|
||||||
|
items = data.get("models") if isinstance(data, dict) else None
|
||||||
|
out: List[str] = []
|
||||||
|
for m in (items or []):
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
v = m.get("name") or m.get("model")
|
||||||
|
if isinstance(v, str) and v:
|
||||||
|
out.append(v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]:
|
def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]:
|
||||||
"""Probe a base URL's /models endpoint and return list of model IDs.
|
"""Probe a base URL's /models endpoint and return list of model IDs.
|
||||||
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
||||||
@@ -759,7 +780,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
models = _openai_model_ids(data)
|
||||||
if models:
|
if models:
|
||||||
return models
|
return models
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
@@ -781,10 +802,10 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
# OpenAI format: {"data": [{"id": "model-name"}]}
|
# OpenAI format: {"data": [{"id": "model-name"}]}
|
||||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
models = _openai_model_ids(data)
|
||||||
# Ollama format: {"models": [{"name": "model-name"}]}
|
# Ollama format: {"models": [{"name": "model-name"}]}
|
||||||
if not models:
|
if not models:
|
||||||
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
|
models = _ollama_model_names(data)
|
||||||
if models:
|
if models:
|
||||||
# Z.AI coding plan omits some working models from /models;
|
# Z.AI coding plan omits some working models from /models;
|
||||||
# append curated-only entries for that endpoint only.
|
# append curated-only entries for that endpoint only.
|
||||||
@@ -810,9 +831,9 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if api_key:
|
if api_key:
|
||||||
logger.warning(f"Failed to probe {url} with API key: {e}")
|
logger.warning("Failed to probe %s with API key: %s", _redact_url_for_log(url), e)
|
||||||
return []
|
return []
|
||||||
logger.warning(f"Failed to probe {url}: {e}")
|
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
||||||
|
|
||||||
# Older Ollama builds and some proxies expose native /api/tags even when
|
# Older Ollama builds and some proxies expose native /api/tags even when
|
||||||
# the OpenAI-compatible /v1/models path is unavailable.
|
# the OpenAI-compatible /v1/models path is unavailable.
|
||||||
@@ -823,7 +844,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
r = httpx.get(root + "/api/tags", timeout=timeout, verify=llm_verify())
|
r = httpx.get(root + "/api/tags", timeout=timeout, verify=llm_verify())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
|
models = _ollama_model_names(data)
|
||||||
if models:
|
if models:
|
||||||
return [m for m in models if _is_chat_model(m)]
|
return [m for m in models if _is_chat_model(m)]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2119,6 +2140,16 @@ def setup_model_routes(model_discovery):
|
|||||||
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
||||||
model = (_user_prefs.get("default_model") or "").strip()
|
model = (_user_prefs.get("default_model") or "").strip()
|
||||||
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
||||||
|
# If user has no personal default, fall back to global default
|
||||||
|
# But only based on the "share_defaults_with_users" flag
|
||||||
|
# (only if share_defaults_with_users is enabled)
|
||||||
|
if settings.get("share_defaults_with_users", False):
|
||||||
|
if not ep_id:
|
||||||
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
|
if not model:
|
||||||
|
model = settings.get("default_model", "")
|
||||||
|
if not _fallbacks:
|
||||||
|
_fallbacks = settings.get("default_model_fallbacks") or []
|
||||||
else:
|
else:
|
||||||
ep_id = settings.get("default_endpoint_id", "")
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
model = settings.get("default_model", "")
|
model = settings.get("default_model", "")
|
||||||
|
|||||||
@@ -335,10 +335,11 @@ async def dispatch_reminder(
|
|||||||
# Loud diagnostic so we can see WHY a reminder didn't send (the
|
# Loud diagnostic so we can see WHY a reminder didn't send (the
|
||||||
# previous "silently no-op when cfg has no smtp_host" was invisible).
|
# previous "silently no-op when cfg has no smtp_host" was invisible).
|
||||||
logger.info(
|
logger.info(
|
||||||
f"dispatch_reminder[email] note_id={note_id} owner={owner!r} "
|
"dispatch_reminder[email] note_id=%s owner=%r "
|
||||||
f"smtp_host={cfg.get('smtp_host')!r} smtp_user={cfg.get('smtp_user')!r} "
|
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
|
||||||
f"from={from_addr!r} recipient={recipient!r} "
|
note_id, owner,
|
||||||
f"account_name={cfg.get('account_name')!r}"
|
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
|
||||||
|
bool(from_addr), bool(recipient),
|
||||||
)
|
)
|
||||||
missing = []
|
missing = []
|
||||||
if not cfg.get("smtp_host"):
|
if not cfg.get("smtp_host"):
|
||||||
|
|||||||
@@ -1377,11 +1377,16 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
pkg["installed"] = False
|
pkg["installed"] = False
|
||||||
except importlib_metadata.PackageNotFoundError:
|
except importlib_metadata.PackageNotFoundError:
|
||||||
pkg["installed"] = False
|
pkg["installed"] = False
|
||||||
except Exception:
|
except (Exception, SystemExit):
|
||||||
# Installed but crashes on import — e.g. a CUDA build of
|
# Installed but crashes on import — e.g. a CUDA build of
|
||||||
# llama-cpp-python raising FileNotFoundError when the CUDA
|
# llama-cpp-python raising FileNotFoundError when the CUDA
|
||||||
# toolkit dir is absent. One broken optional package must not
|
# toolkit dir is absent, or rembg calling sys.exit(1) when no
|
||||||
# 500 the entire packages panel; report it as not usable.
|
# onnxruntime backend can be loaded. SystemExit is a
|
||||||
|
# BaseException, not Exception, so without catching it here a
|
||||||
|
# single sys.exit-on-import package escapes and takes down the
|
||||||
|
# whole packages panel / worker (the panel hangs forever). One
|
||||||
|
# broken optional package must not 500 — or hang — the entire
|
||||||
|
# panel; report it as not usable.
|
||||||
pkg["installed"] = False
|
pkg["installed"] = False
|
||||||
|
|
||||||
# llama_cpp partial-state probe: when the package is installed
|
# llama_cpp partial-state probe: when the package is installed
|
||||||
|
|||||||
@@ -14059,6 +14059,138 @@
|
|||||||
"vision"
|
"vision"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "google/gemma-4-12B-it",
|
||||||
|
"provider": "Google",
|
||||||
|
"parameter_count": "12.0B",
|
||||||
|
"parameters_raw": 12000000000,
|
||||||
|
"min_ram_gb": 8.5,
|
||||||
|
"recommended_ram_gb": 11.0,
|
||||||
|
"min_vram_gb": 7.5,
|
||||||
|
"quantization": "Q4_K_M",
|
||||||
|
"context_length": 131072,
|
||||||
|
"use_case": "General purpose, multimodal; unsloth/gemma-4-12B-it-GGUF Dynamic variants reduce VRAM from ~7.5 GB to ~5.5 GB",
|
||||||
|
"is_moe": false,
|
||||||
|
"num_experts": null,
|
||||||
|
"active_experts": null,
|
||||||
|
"active_parameters": null,
|
||||||
|
"architecture": "gemma4",
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"release_date": "2026-04-01",
|
||||||
|
"gguf_sources": [
|
||||||
|
{
|
||||||
|
"repo": "unsloth/gemma-4-12B-it-GGUF",
|
||||||
|
"provider": "unsloth"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
"vision"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "google/gemma-4-12B-it-qat-int4",
|
||||||
|
"provider": "Google",
|
||||||
|
"parameter_count": "12.0B",
|
||||||
|
"parameters_raw": 12000000000,
|
||||||
|
"min_ram_gb": 8.0,
|
||||||
|
"recommended_ram_gb": 9.5,
|
||||||
|
"min_vram_gb": 6.5,
|
||||||
|
"quantization": "QAT-INT4",
|
||||||
|
"context_length": 131072,
|
||||||
|
"use_case": "General purpose, multimodal (QAT quantization-aware training — higher quality than post-train INT4; vLLM native; no GGUF)",
|
||||||
|
"is_moe": false,
|
||||||
|
"num_experts": null,
|
||||||
|
"active_experts": null,
|
||||||
|
"active_parameters": null,
|
||||||
|
"architecture": "gemma4",
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"release_date": "2026-04-01",
|
||||||
|
"gguf_sources": [],
|
||||||
|
"capabilities": [
|
||||||
|
"vision"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "google/gemma-4-12B-it-qat-int8",
|
||||||
|
"provider": "Google",
|
||||||
|
"parameter_count": "12.0B",
|
||||||
|
"parameters_raw": 12000000000,
|
||||||
|
"min_ram_gb": 15.0,
|
||||||
|
"recommended_ram_gb": 20.0,
|
||||||
|
"min_vram_gb": 13.5,
|
||||||
|
"quantization": "QAT-INT8",
|
||||||
|
"context_length": 131072,
|
||||||
|
"use_case": "General purpose, multimodal (QAT INT8 — highest quality, 2x VRAM of QAT-INT4; vLLM native; no GGUF)",
|
||||||
|
"is_moe": false,
|
||||||
|
"num_experts": null,
|
||||||
|
"active_experts": null,
|
||||||
|
"active_parameters": null,
|
||||||
|
"architecture": "gemma4",
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"release_date": "2026-04-01",
|
||||||
|
"gguf_sources": [],
|
||||||
|
"capabilities": [
|
||||||
|
"vision"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "google/gemma-4-12B-it-qat-q4_0-gguf",
|
||||||
|
"provider": "Google",
|
||||||
|
"parameter_count": "12.0B",
|
||||||
|
"parameters_raw": 12000000000,
|
||||||
|
"min_ram_gb": 8.5,
|
||||||
|
"recommended_ram_gb": 11.0,
|
||||||
|
"min_vram_gb": 7.5,
|
||||||
|
"quantization": "QAT-INT4",
|
||||||
|
"context_length": 262144,
|
||||||
|
"use_case": "General purpose, multimodal (vision + audio); official Google QAT int4 GGUF — near-bf16 quality at int4 size, served on llama.cpp/Ollama with CPU offload",
|
||||||
|
"is_moe": false,
|
||||||
|
"num_experts": null,
|
||||||
|
"active_experts": null,
|
||||||
|
"active_parameters": null,
|
||||||
|
"architecture": "gemma4",
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"release_date": "2026-04-01",
|
||||||
|
"gguf_sources": [
|
||||||
|
{
|
||||||
|
"repo": "google/gemma-4-12B-it-qat-q4_0-gguf",
|
||||||
|
"provider": "Google",
|
||||||
|
"file": "gemma-4-12b-it-qat-q4_0.gguf"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
"vision",
|
||||||
|
"audio"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "google/gemma-4-26B-A4B-it-qat-q4_0-gguf",
|
||||||
|
"provider": "Google",
|
||||||
|
"parameter_count": "25.2B",
|
||||||
|
"parameters_raw": 25200000000,
|
||||||
|
"min_ram_gb": 14.4,
|
||||||
|
"recommended_ram_gb": 18.0,
|
||||||
|
"min_vram_gb": 14.4,
|
||||||
|
"quantization": "QAT-INT4",
|
||||||
|
"context_length": 262144,
|
||||||
|
"use_case": "High-throughput, multimodal MoE (3.8B active); official Google QAT int4 GGUF — near-bf16 quality at int4 size, served on llama.cpp with CPU offload",
|
||||||
|
"is_moe": true,
|
||||||
|
"num_experts": null,
|
||||||
|
"active_experts": null,
|
||||||
|
"active_parameters": 3800000000,
|
||||||
|
"architecture": "gemma4",
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"release_date": "2026-04-01",
|
||||||
|
"gguf_sources": [
|
||||||
|
{
|
||||||
|
"repo": "google/gemma-4-26B-A4B-it-qat-q4_0-gguf",
|
||||||
|
"provider": "Google"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
"vision"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "google/gemma-4-31B-it",
|
"name": "google/gemma-4-31B-it",
|
||||||
"provider": "Google",
|
"provider": "Google",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from services.hwfit.models import (
|
|||||||
GPU_BANDWIDTH = {
|
GPU_BANDWIDTH = {
|
||||||
"5090": 1792, "5080": 960, "5070 ti": 896, "5070": 672, "5060 ti": 448, "5060": 256,
|
"5090": 1792, "5080": 960, "5070 ti": 896, "5070": 672, "5060 ti": 448, "5060": 256,
|
||||||
"4090": 1008, "4080 super": 736, "4080": 717, "4070 ti super": 672, "4070 ti": 504, "4070 super": 504, "4070": 504, "4060 ti": 288, "4060": 272,
|
"4090": 1008, "4080 super": 736, "4080": 717, "4070 ti super": 672, "4070 ti": 504, "4070 super": 504, "4070": 504, "4060 ti": 288, "4060": 272,
|
||||||
"3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360,
|
"3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360, "3050 ti": 192, "3050": 224,
|
||||||
"2080 ti": 616, "2080 super": 496, "2080": 448, "2070 super": 448, "2070": 448, "2060 super": 448, "2060": 336,
|
"2080 ti": 616, "2080 super": 496, "2080": 448, "2070 super": 448, "2070": 448, "2060 super": 448, "2060": 336,
|
||||||
"1660 ti": 288, "1660 super": 336, "1660": 192, "1650 super": 192, "1650": 128,
|
"1660 ti": 288, "1660 super": 336, "1660": 192, "1650 super": 192, "1650": 128,
|
||||||
"h100 sxm": 3350, "h100": 2039, "h200": 4800, "a100 sxm": 2039, "a100": 1555,
|
"h100 sxm": 3350, "h100": 2039, "h200": 4800, "a100 sxm": 2039, "a100": 1555,
|
||||||
|
|||||||
@@ -538,6 +538,32 @@ def _powershell_exe():
|
|||||||
path so we don't depend on a particular PATH ordering."""
|
path so we don't depend on a particular PATH ordering."""
|
||||||
return shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
return shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
||||||
|
|
||||||
|
def _powershell_encoded_for_ssh(script: str):
|
||||||
|
"""Run a PowerShell script on a remote Windows host over SSH.
|
||||||
|
|
||||||
|
Nested quotes in powershell -Command break when passed through Windows
|
||||||
|
OpenSSH's cmd wrapper; -EncodedCommand avoids that.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||||
|
return _run(f"powershell -NoProfile -EncodedCommand {encoded}")
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_remote_platform():
|
||||||
|
"""Best-effort OS detection over SSH when the caller didn't pass platform."""
|
||||||
|
out = _run("echo %OS%")
|
||||||
|
if out and "Windows_NT" in out:
|
||||||
|
return "windows"
|
||||||
|
uname = (_run(["uname", "-s"]) or "").strip().lower()
|
||||||
|
if uname == "darwin":
|
||||||
|
# Mac uses the linux detection path (_detect_apple_silicon over SSH).
|
||||||
|
return "linux"
|
||||||
|
if uname == "linux":
|
||||||
|
out = _run("test -d /data/data/com.termux && echo termux || echo linux")
|
||||||
|
if out and "termux" in out:
|
||||||
|
return "termux"
|
||||||
|
return "linux"
|
||||||
|
|
||||||
|
|
||||||
def _detect_windows():
|
def _detect_windows():
|
||||||
"""Detect Windows hardware via PowerShell/WMI.
|
"""Detect Windows hardware via PowerShell/WMI.
|
||||||
@@ -600,9 +626,8 @@ def _detect_windows():
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
if _remote_host:
|
if _remote_host:
|
||||||
# Remote: ship a single command string over SSH. The remote shell parses
|
# Remote: use -EncodedCommand so OpenSSH/cmd quoting does not break the script.
|
||||||
# the quoting; PowerShell on the far side runs the -Command payload.
|
out = _powershell_encoded_for_ssh(ps_cmd.strip())
|
||||||
out = _run(f'powershell -Command "{ps_cmd}"')
|
|
||||||
else:
|
else:
|
||||||
# Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd
|
# Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd
|
||||||
# to PowerShell verbatim — no fragile string-level quote escaping. Prefer
|
# to PowerShell verbatim — no fragile string-level quote escaping. Prefer
|
||||||
@@ -773,6 +798,13 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
"""
|
"""
|
||||||
global _remote_host, _remote_port, _remote_platform
|
global _remote_host, _remote_port, _remote_platform
|
||||||
|
|
||||||
|
if host and not platform:
|
||||||
|
_remote_host = host
|
||||||
|
_remote_port = ssh_port or None
|
||||||
|
platform = _probe_remote_platform()
|
||||||
|
_remote_host = None
|
||||||
|
_remote_port = None
|
||||||
|
|
||||||
cache_key = _cache_key(host, ssh_port, platform)
|
cache_key = _cache_key(host, ssh_port, platform)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if not fresh and cache_key in _cache_by_host:
|
if not fresh and cache_key in _cache_by_host:
|
||||||
@@ -793,8 +825,8 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
_remote_platform = None
|
_remote_platform = None
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
return result
|
return result
|
||||||
# If Windows detection failed, return error
|
# SSH may work while the PowerShell hardware probe still fails.
|
||||||
result = {"error": f"Cannot connect to {host}", "host": host}
|
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
|
||||||
_remote_host = None
|
_remote_host = None
|
||||||
_remote_platform = None
|
_remote_platform = None
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ QUANT_BPP = {
|
|||||||
"Q4_K_M": 0.58, "Q4_0": 0.58, "Q3_K_M": 0.48, "Q2_K": 0.37,
|
"Q4_K_M": 0.58, "Q4_0": 0.58, "Q3_K_M": 0.48, "Q2_K": 0.37,
|
||||||
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
|
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
|
||||||
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
|
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
|
||||||
|
"QAT-INT4": 0.50, "QAT-INT8": 1.0,
|
||||||
"mlx-4bit": 0.55, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
|
"mlx-4bit": 0.55, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
|
||||||
# DeepSeek-V4-style mixed: MoE experts in FP4 (bulk), attention + non-
|
# DeepSeek-V4-style mixed: MoE experts in FP4 (bulk), attention + non-
|
||||||
# expert dense in FP8, embeddings/LM head in BF16. By weight count the
|
# expert dense in FP8, embeddings/LM head in BF16. By weight count the
|
||||||
@@ -30,6 +31,7 @@ QUANT_SPEED_MULT = {
|
|||||||
"Q4_K_M": 1.15, "Q4_0": 1.15, "Q3_K_M": 1.25, "Q2_K": 1.35,
|
"Q4_K_M": 1.15, "Q4_0": 1.15, "Q3_K_M": 1.25, "Q2_K": 1.35,
|
||||||
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
|
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
|
||||||
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
|
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
|
||||||
|
"QAT-INT4": 1.15, "QAT-INT8": 0.85,
|
||||||
"mlx-4bit": 1.15, "mlx-8bit": 0.85, "mlx-6bit": 1.0,
|
"mlx-4bit": 1.15, "mlx-8bit": 0.85, "mlx-6bit": 1.0,
|
||||||
"FP4-MoE-Mixed": 1.10, # slightly slower than pure FP4 because of mixed-dtype dispatch
|
"FP4-MoE-Mixed": 1.10, # slightly slower than pure FP4 because of mixed-dtype dispatch
|
||||||
"FP8-Mixed": 0.85,
|
"FP8-Mixed": 0.85,
|
||||||
@@ -47,6 +49,10 @@ QUANT_QUALITY_PENALTY = {
|
|||||||
# penalty so FP8 wins when both fit. AWQ-4bit stays heavier.
|
# penalty so FP8 wins when both fit. AWQ-4bit stays heavier.
|
||||||
"AWQ": -1.0, "AWQ-4bit": -4.0, "AWQ-8bit": -1.0,
|
"AWQ": -1.0, "AWQ-4bit": -4.0, "AWQ-8bit": -1.0,
|
||||||
"GPTQ": -1.0, "GPTQ-Int4": -4.0, "GPTQ-Int8": -1.0,
|
"GPTQ": -1.0, "GPTQ-Int4": -4.0, "GPTQ-Int8": -1.0,
|
||||||
|
# Quantization-aware training recovers most of the int4 quality loss, so a
|
||||||
|
# QAT-INT4 build lands far closer to bf16 than a post-training Q4/INT4
|
||||||
|
# (Google reports near-bf16 quality). Penalize it lightly, not like Q4_K_M.
|
||||||
|
"QAT-INT4": -1.0, "QAT-INT8": 0.0,
|
||||||
"mlx-4bit": -4.0, "mlx-8bit": -0.5, "mlx-6bit": -1.5,
|
"mlx-4bit": -4.0, "mlx-8bit": -0.5, "mlx-6bit": -1.5,
|
||||||
# DeepSeek-V4 mixed: only MoE experts at FP4 (the rest is FP8/BF16),
|
# DeepSeek-V4 mixed: only MoE experts at FP4 (the rest is FP8/BF16),
|
||||||
# so the realized quality is much closer to FP8 than to pure FP4 —
|
# so the realized quality is much closer to FP8 than to pure FP4 —
|
||||||
@@ -63,6 +69,7 @@ QUANT_BYTES_PER_PARAM = {
|
|||||||
"Q4_K_M": 0.5, "Q4_0": 0.5, "Q3_K_M": 0.375, "Q2_K": 0.25,
|
"Q4_K_M": 0.5, "Q4_0": 0.5, "Q3_K_M": 0.375, "Q2_K": 0.25,
|
||||||
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
|
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
|
||||||
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
|
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
|
||||||
|
"QAT-INT4": 0.5, "QAT-INT8": 1.0,
|
||||||
"mlx-4bit": 0.5, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
|
"mlx-4bit": 0.5, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
|
||||||
"FP4-MoE-Mixed": 0.55,
|
"FP4-MoE-Mixed": 0.55,
|
||||||
"FP8-Mixed": 1.0,
|
"FP8-Mixed": 1.0,
|
||||||
@@ -74,6 +81,7 @@ PREQUANTIZED_PREFIXES = (
|
|||||||
"AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
|
"AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
|
||||||
"INT4", "INT8", "W4A16", "W8A8", "W8A16",
|
"INT4", "INT8", "W4A16", "W8A8", "W8A16",
|
||||||
"FP4-MoE-Mixed", "FP8-Mixed",
|
"FP4-MoE-Mixed", "FP8-Mixed",
|
||||||
|
"QAT-",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,15 @@ def check_arch():
|
|||||||
def main():
|
def main():
|
||||||
print("\n=== Odysseus Setup ===\n")
|
print("\n=== Odysseus Setup ===\n")
|
||||||
|
|
||||||
|
# Load .env so pre-seeded ODYSSEUS_ADMIN_USER / ODYSSEUS_ADMIN_PASSWORD (and
|
||||||
|
# other deployment vars) are honored on native installs, not just when they
|
||||||
|
# are exported in the shell. Mirrors app.py: encoding="utf-8-sig" tolerates a
|
||||||
|
# UTF-8 BOM in a Notepad-saved .env. load_dotenv does not override already
|
||||||
|
# exported OS env vars, so the existing precedence is preserved. python-dotenv
|
||||||
|
# is a hard dependency (requirements.txt) and is verified by check_deps below.
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(BASE_DIR, ".env"), encoding="utf-8-sig")
|
||||||
|
|
||||||
# Fail fast with a clear message if the CPU architecture is wrong (Apple
|
# Fail fast with a clear message if the CPU architecture is wrong (Apple
|
||||||
# Silicon under an x86/Rosetta Python) before importing anything native.
|
# Silicon under an x86/Rosetta Python) before importing anything native.
|
||||||
check_arch()
|
check_arch()
|
||||||
|
|||||||
+247
-20
@@ -38,6 +38,167 @@ from src.agent_tools import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Redaction patterns for common secret-bearing shapes. Explicit and tested
|
||||||
|
# (see tests/test_loop_guard_signals.py) rather than one clever broad regex —
|
||||||
|
# safety first, but we try not to mangle harmless prose. Applied in order.
|
||||||
|
_REDACTED = "[redacted]"
|
||||||
|
|
||||||
|
# Cookie: ... / Set-Cookie: ... — redact the rest of the line (cookies hold spaces).
|
||||||
|
_SENSITIVE_COOKIE_RE = re.compile(
|
||||||
|
r"(?i)\b((?:set-)?cookie\s*[:=]\s*)[^\r\n]+"
|
||||||
|
)
|
||||||
|
# URL credentials, e.g. postgres://user:pass@host/db. The password half allows
|
||||||
|
# inner colons (postgres://user:pa:ss@host/db) but still stops at / and @.
|
||||||
|
_SENSITIVE_URL_CRED_RE = re.compile(
|
||||||
|
r"(?i)\b([a-z][a-z0-9+.\-]*://)[^\s:/@]+:[^\s/@]+@"
|
||||||
|
)
|
||||||
|
# Prefix-only discovery regexes. Each matches the key and its separator (the part
|
||||||
|
# we KEEP); the value that follows is found by a linear scanner rather than by a
|
||||||
|
# regex, so there is no backtracking-prone quantifier over uncontrolled input.
|
||||||
|
#
|
||||||
|
# Authorization: Bearer <tok> / Authorization: Basic "two word secret"
|
||||||
|
_AUTH_PREFIX_RE = re.compile(
|
||||||
|
r"(?i)authorization\s*[:=]\s*(?:bearer|basic)\s+"
|
||||||
|
)
|
||||||
|
# Provider-prefixed env names, e.g. OPENAI_API_KEY=..., AWS_SECRET_ACCESS_KEY=...,
|
||||||
|
# GITHUB_TOKEN=... — require a sensitive suffix preceded by `_` so benign names
|
||||||
|
# that merely end in KEY (MONKEY, TURKEY) are left alone.
|
||||||
|
_ENV_PREFIX_RE = re.compile(
|
||||||
|
r"(?:export\s+)?\b[A-Z][A-Z0-9_]*"
|
||||||
|
r"_(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|CREDENTIALS?)\s*=\s*"
|
||||||
|
)
|
||||||
|
# Generic sensitive key, e.g. password=..., api_key: ..., client_secret=...
|
||||||
|
_KEY_PREFIX_RE = re.compile(
|
||||||
|
r"(?i)\b(?:password|passwd|pwd|token|api[_-]?key|client_secret|secret)\b\s*[:=]\s*"
|
||||||
|
)
|
||||||
|
# Obvious provider-shaped bare tokens (no surrounding key needed).
|
||||||
|
_SENSITIVE_BARE_TOKEN_RE = re.compile(
|
||||||
|
r"\b("
|
||||||
|
r"sk-[A-Za-z0-9_\-]{16,}" # OpenAI / Anthropic style
|
||||||
|
r"|gh[pousr]_[A-Za-z0-9]{20,}" # GitHub PAT
|
||||||
|
r"|xox[baprs]-[A-Za-z0-9\-]{10,}" # Slack
|
||||||
|
r"|AKIA[0-9A-Z]{16}" # AWS access key id
|
||||||
|
r"|hf_[A-Za-z0-9]{16,}" # Hugging Face token
|
||||||
|
r"|AIza[0-9A-Za-z_\-]{20,}" # Google API key
|
||||||
|
r")\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_secret_value_end(text: str, start: int) -> int:
|
||||||
|
"""Return the exclusive end index of the secret value beginning at ``start``.
|
||||||
|
|
||||||
|
If the value is quoted, scan to the matching unescaped quote (backslash
|
||||||
|
escapes are skipped two chars at a time). Otherwise scan to the first
|
||||||
|
whitespace, comma, or semicolon. The scan is linear in the length of the
|
||||||
|
input, so it cannot exhibit catastrophic backtracking.
|
||||||
|
"""
|
||||||
|
n = len(text)
|
||||||
|
if start >= n:
|
||||||
|
return start
|
||||||
|
quote = text[start]
|
||||||
|
if quote in ("'", '"'):
|
||||||
|
i = start + 1
|
||||||
|
while i < n:
|
||||||
|
ch = text[i]
|
||||||
|
if ch == "\\":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if ch == quote:
|
||||||
|
return i + 1
|
||||||
|
i += 1
|
||||||
|
return n # unterminated quote: redact to the end
|
||||||
|
i = start
|
||||||
|
while i < n and not text[i].isspace() and text[i] not in (",", ";"):
|
||||||
|
i += 1
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_after_prefix(text: str, prefix_re: "re.Pattern") -> str:
|
||||||
|
"""Redact the value following each ``prefix_re`` match using a linear scan."""
|
||||||
|
result = []
|
||||||
|
pos = 0
|
||||||
|
n = len(text)
|
||||||
|
while pos < n:
|
||||||
|
match = prefix_re.search(text, pos)
|
||||||
|
if match is None:
|
||||||
|
result.append(text[pos:])
|
||||||
|
break
|
||||||
|
result.append(text[pos:match.end()])
|
||||||
|
value_end = _consume_secret_value_end(text, match.end())
|
||||||
|
if value_end > match.end():
|
||||||
|
result.append(_REDACTED)
|
||||||
|
pos = value_end
|
||||||
|
else:
|
||||||
|
# Empty value: nothing to redact; step past the prefix and continue.
|
||||||
|
pos = match.end()
|
||||||
|
if pos < n:
|
||||||
|
result.append(text[pos])
|
||||||
|
pos += 1
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_private_keys(text: str) -> str:
|
||||||
|
"""Replace PEM private-key blocks with a placeholder via linear scanning.
|
||||||
|
|
||||||
|
Finds ``-----BEGIN `` markers, verifies the header names a PRIVATE KEY,
|
||||||
|
locates the matching ``-----END `` marker, and collapses the whole block.
|
||||||
|
No regex is used, so the (multi-line, uncontrolled) body cannot trigger
|
||||||
|
polynomial matching.
|
||||||
|
"""
|
||||||
|
begin_marker = "-----BEGIN "
|
||||||
|
end_marker = "-----END "
|
||||||
|
dash = "-----"
|
||||||
|
max_header = 64 # generous bound on "[TYPE ]PRIVATE KEY"
|
||||||
|
result = []
|
||||||
|
pos = 0
|
||||||
|
while True:
|
||||||
|
begin = text.find(begin_marker, pos)
|
||||||
|
if begin == -1:
|
||||||
|
result.append(text[pos:])
|
||||||
|
return "".join(result)
|
||||||
|
header_start = begin + len(begin_marker)
|
||||||
|
header_close = text.find(dash, header_start)
|
||||||
|
if (
|
||||||
|
header_close == -1
|
||||||
|
or header_close - header_start > max_header
|
||||||
|
or not text[header_start:header_close].endswith("PRIVATE KEY")
|
||||||
|
):
|
||||||
|
result.append(text[pos:header_start])
|
||||||
|
pos = header_start
|
||||||
|
continue
|
||||||
|
end = text.find(end_marker, header_close)
|
||||||
|
if end == -1:
|
||||||
|
result.append(text[pos:])
|
||||||
|
return "".join(result)
|
||||||
|
end_header_start = end + len(end_marker)
|
||||||
|
end_close = text.find(dash, end_header_start)
|
||||||
|
if (
|
||||||
|
end_close == -1
|
||||||
|
or end_close - end_header_start > max_header
|
||||||
|
or not text[end_header_start:end_close].endswith("PRIVATE KEY")
|
||||||
|
):
|
||||||
|
result.append(text[pos:header_start])
|
||||||
|
pos = header_start
|
||||||
|
continue
|
||||||
|
result.append(text[pos:begin])
|
||||||
|
result.append("[redacted private key]")
|
||||||
|
pos = end_close + len(dash)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_sensitive_text(value: object) -> str:
|
||||||
|
"""Redact obvious credential values before surfacing tool output."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = str(value)
|
||||||
|
text = _redact_private_keys(text)
|
||||||
|
text = _redact_after_prefix(text, _AUTH_PREFIX_RE)
|
||||||
|
text = _SENSITIVE_COOKIE_RE.sub(r"\1" + _REDACTED, text)
|
||||||
|
text = _SENSITIVE_URL_CRED_RE.sub(r"\1" + _REDACTED + "@", text)
|
||||||
|
text = _redact_after_prefix(text, _ENV_PREFIX_RE)
|
||||||
|
text = _redact_after_prefix(text, _KEY_PREFIX_RE)
|
||||||
|
return _SENSITIVE_BARE_TOKEN_RE.sub(_REDACTED, text)
|
||||||
|
|
||||||
|
|
||||||
def _load_mcp_disabled_map() -> Dict[str, set]:
|
def _load_mcp_disabled_map() -> Dict[str, set]:
|
||||||
"""Load per-server disabled tool sets from the database."""
|
"""Load per-server disabled tool sets from the database."""
|
||||||
@@ -2455,6 +2616,7 @@ async def stream_agent_loop(
|
|||||||
# signatures + consecutive no-text tool rounds to bail early.
|
# signatures + consecutive no-text tool rounds to bail early.
|
||||||
_recent_call_sigs = collections.deque(maxlen=6)
|
_recent_call_sigs = collections.deque(maxlen=6)
|
||||||
_stuck_rounds = 0
|
_stuck_rounds = 0
|
||||||
|
_MAX_STUCK_ROUNDS = 4 # consecutive no-progress rounds before loop-breaker bails
|
||||||
# Frequency of each exact call signature (tool + args), for the runaway
|
# Frequency of each exact call signature (tool + args), for the runaway
|
||||||
# backstop. Counting identical repeats — not distinct same-tool calls —
|
# backstop. Counting identical repeats — not distinct same-tool calls —
|
||||||
# lets a legit batch (e.g. 18 calendar events at once) through.
|
# lets a legit batch (e.g. 18 calendar events at once) through.
|
||||||
@@ -2932,17 +3094,22 @@ async def stream_agent_loop(
|
|||||||
# promise: short response (<400 chars), no fenced code/answer,
|
# promise: short response (<400 chars), no fenced code/answer,
|
||||||
# and an action-intent phrase was matched. Long answers that
|
# and an action-intent phrase was matched. Long answers that
|
||||||
# happen to contain "let me know" are not stalls.
|
# happen to contain "let me know" are not stalls.
|
||||||
_looks_like_promise = (
|
_promise_shape = (
|
||||||
not guide_only
|
not guide_only
|
||||||
and _intent_match is not None
|
and _intent_match is not None
|
||||||
and len(_intent_text) < 400
|
and len(_intent_text) < 400
|
||||||
and "```" not in _intent_text
|
and "```" not in _intent_text
|
||||||
and _intent_nudge_count < _MAX_INTENT_NUDGES
|
|
||||||
)
|
)
|
||||||
|
_looks_like_promise = _promise_shape and _intent_nudge_count < _MAX_INTENT_NUDGES
|
||||||
if _looks_like_promise:
|
if _looks_like_promise:
|
||||||
_intent_nudge_count += 1
|
_intent_nudge_count += 1
|
||||||
_matched_phrase = _intent_match.group(0).strip()
|
_matched_phrase = _intent_match.group(0).strip()
|
||||||
logger.info(f"[agent] intent-without-action nudge #{_intent_nudge_count} on round {round_num}: {_matched_phrase!r}")
|
# Don't log the matched phrase — it's raw model text that may
|
||||||
|
# carry credentials. Structural metadata only.
|
||||||
|
logger.info(
|
||||||
|
"[agent] intent-without-action nudge #%d on round %d",
|
||||||
|
_intent_nudge_count, round_num,
|
||||||
|
)
|
||||||
_lower_phrase = _matched_phrase.lower()
|
_lower_phrase = _matched_phrase.lower()
|
||||||
_cookbook_log_hint = ""
|
_cookbook_log_hint = ""
|
||||||
if any(_word in _lower_phrase for _word in ("log", "logs", "output", "tail", "status")):
|
if any(_word in _lower_phrase for _word in ("log", "logs", "output", "tail", "status")):
|
||||||
@@ -2968,6 +3135,24 @@ async def stream_agent_loop(
|
|||||||
# Visible signal in the stream so the user knows we caught it.
|
# Visible signal in the stream so the user knows we caught it.
|
||||||
yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n'
|
yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n'
|
||||||
continue
|
continue
|
||||||
|
# The model keeps announcing actions it never takes and we've spent
|
||||||
|
# every nudge — surface why the turn is ending instead of letting it
|
||||||
|
# look like a clean completion.
|
||||||
|
if _promise_shape and _intent_nudge_count >= _MAX_INTENT_NUDGES:
|
||||||
|
_matched_phrase = _intent_match.group(0).strip()
|
||||||
|
_matched_phrase_safe = _redact_sensitive_text(_matched_phrase)
|
||||||
|
_in_message = (
|
||||||
|
f"Intent-nudge cap reached on round {round_num}: the model "
|
||||||
|
f"announced an action ({_matched_phrase_safe!r}) without a tool call "
|
||||||
|
f"after {_intent_nudge_count} nudge(s); ending the turn."
|
||||||
|
)
|
||||||
|
# Do not log the matched phrase, even redacted. It is raw model
|
||||||
|
# text and may contain credentials; keep logs structural only.
|
||||||
|
logger.warning(
|
||||||
|
"[agent] intent-nudge cap exhausted on round %d (%d/%d)",
|
||||||
|
round_num, _intent_nudge_count, _MAX_INTENT_NUDGES,
|
||||||
|
)
|
||||||
|
yield f'data: {json.dumps({"type": "intent_nudge_exhausted", "round": round_num, "nudges": _intent_nudge_count, "max_nudges": _MAX_INTENT_NUDGES, "message": _in_message})}\n\n'
|
||||||
break # no tools — done
|
break # no tools — done
|
||||||
|
|
||||||
# ── Loop-breaker (Terminus-style stall detector) ──────────────
|
# ── Loop-breaker (Terminus-style stall detector) ──────────────
|
||||||
@@ -3000,10 +3185,23 @@ async def stream_agent_loop(
|
|||||||
# Distinct calls to one tool (a real batch) are legitimate work, so we
|
# Distinct calls to one tool (a real batch) are legitimate work, so we
|
||||||
# count identical call signatures, not raw per-tool-type totals.
|
# count identical call signatures, not raw per-tool-type totals.
|
||||||
_runaway = _detect_runaway_call(_call_freq)
|
_runaway = _detect_runaway_call(_call_freq)
|
||||||
if _stuck_rounds >= 4 or _runaway:
|
if _stuck_rounds >= _MAX_STUCK_ROUNDS or _runaway:
|
||||||
reason = (f"calling {_runaway} with identical arguments over and over" if _runaway
|
reason = (f"calling {_runaway} with identical arguments over and over" if _runaway
|
||||||
else "repeating the same tool calls without new progress")
|
else "repeating the same tool calls without new progress")
|
||||||
logger.warning(f"[agent] loop-breaker tripped on round {round_num} ({reason}); sig={_sig[:80]!r}")
|
_lb_message = (
|
||||||
|
f"Loop-breaker stopped the agent on round {round_num}: {reason}. "
|
||||||
|
"Forced one tool-free round to converge on an answer or state what's blocked."
|
||||||
|
)
|
||||||
|
# Log structural metadata only — `_sig` is raw tool-call content
|
||||||
|
# that may carry credentials.
|
||||||
|
logger.warning(
|
||||||
|
"[agent] loop-breaker tripped on round %d (%s); "
|
||||||
|
"stuck_rounds=%d/%d runaway=%r",
|
||||||
|
round_num, reason, _stuck_rounds, _MAX_STUCK_ROUNDS, _runaway,
|
||||||
|
)
|
||||||
|
# Surface the stop cause to the stream so the user (and journalctl)
|
||||||
|
# can tell a guard fired, not a clean completion.
|
||||||
|
yield f'data: {json.dumps({"type": "loop_breaker_triggered", "round": round_num, "reason": reason, "stuck_rounds": _stuck_rounds, "max_stuck_rounds": _MAX_STUCK_ROUNDS, "runaway": _runaway, "message": _lb_message})}\n\n'
|
||||||
# The model has been executing tools, so its results are already
|
# The model has been executing tools, so its results are already
|
||||||
# in context. Force ONE tool-free round to converge: write the
|
# in context. Force ONE tool-free round to converge: write the
|
||||||
# answer from what it has, or state plainly what's blocking it.
|
# answer from what it has, or state plainly what's blocking it.
|
||||||
@@ -3082,6 +3280,10 @@ async def stream_agent_loop(
|
|||||||
cmd_display = block.content.split("\n")[0].strip()[:80]
|
cmd_display = block.content.split("\n")[0].strip()[:80]
|
||||||
else:
|
else:
|
||||||
cmd_display = block.content.strip()
|
cmd_display = block.content.strip()
|
||||||
|
# The display string is streamed (tool_start/tool_output) and persisted;
|
||||||
|
# redact any secrets in it. block.content itself is left untouched so
|
||||||
|
# tool execution still sees the real command.
|
||||||
|
cmd_display = _redact_sensitive_text(cmd_display)
|
||||||
|
|
||||||
if tool_policy and tool_policy.blocks(block.tool_type):
|
if tool_policy and tool_policy.blocks(block.tool_type):
|
||||||
desc = f"{block.tool_type}: BLOCKED"
|
desc = f"{block.tool_type}: BLOCKED"
|
||||||
@@ -3127,8 +3329,15 @@ async def stream_agent_loop(
|
|||||||
evt = await _progress_q.get()
|
evt = await _progress_q.get()
|
||||||
if evt is None:
|
if evt is None:
|
||||||
break
|
break
|
||||||
|
# Redact secrets in the live tail before streaming — the
|
||||||
|
# final tool_output is redacted, so the progress tail must
|
||||||
|
# be too, or a secret could flash by mid-run. Copy so we
|
||||||
|
# don't mutate the tool's own event payload.
|
||||||
|
_evt = dict(evt)
|
||||||
|
if isinstance(_evt.get("tail"), str):
|
||||||
|
_evt["tail"] = _redact_sensitive_text(_evt["tail"])
|
||||||
yield (
|
yield (
|
||||||
f'data: {json.dumps({"type": "tool_progress", "tool": block.tool_type, "round": round_num, **evt})}\n\n'
|
f'data: {json.dumps({"type": "tool_progress", "tool": block.tool_type, "round": round_num, **_evt})}\n\n'
|
||||||
)
|
)
|
||||||
desc, result = await _tool_task
|
desc, result = await _tool_task
|
||||||
|
|
||||||
@@ -3194,7 +3403,7 @@ async def stream_agent_loop(
|
|||||||
result["results"] = _clean
|
result["results"] = _clean
|
||||||
elif "stdout" in result:
|
elif "stdout" in result:
|
||||||
result["stdout"] = _clean
|
result["stdout"] = _clean
|
||||||
except (json.JSONDecodeError, Exception):
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Emit doc-specific event for document tools — the frontend
|
# Emit doc-specific event for document tools — the frontend
|
||||||
@@ -3215,9 +3424,12 @@ async def stream_agent_loop(
|
|||||||
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
|
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# ask_user: the agent posed a multiple-choice question. Emit it so the
|
# ask_user: remember the payload now, but emit the interactive event
|
||||||
# frontend renders clickable options, then end the turn (below) and
|
# only *after* tool_output below. Emitting it before tool_output let
|
||||||
# wait — the user's pick becomes the next message.
|
# the subsequent tool-card rewrite/scroll push the choices out of
|
||||||
|
# view. The payload is also copied into the persisted tool event so
|
||||||
|
# history reload can reconstruct an unanswered card.
|
||||||
|
_pending_ask_user_event = None
|
||||||
if "ask_user" in result:
|
if "ask_user" in result:
|
||||||
# The question lives in the tool args. ChatMessage.to_dict()
|
# The question lives in the tool args. ChatMessage.to_dict()
|
||||||
# replays only role+content to the model next turn — tool_event
|
# replays only role+content to the model next turn — tool_event
|
||||||
@@ -3232,9 +3444,7 @@ async def stream_agent_loop(
|
|||||||
_auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q
|
_auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q
|
||||||
full_response += _auq_delta
|
full_response += _auq_delta
|
||||||
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
|
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
|
||||||
yield (
|
_pending_ask_user_event = _auq
|
||||||
f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n'
|
|
||||||
)
|
|
||||||
_awaiting_user = True
|
_awaiting_user = True
|
||||||
|
|
||||||
# update_plan: agent wrote back to the plan (ticked a step / revised).
|
# update_plan: agent wrote back to the plan (ticked a step / revised).
|
||||||
@@ -3263,32 +3473,36 @@ async def stream_agent_loop(
|
|||||||
# empty) stdout/stderr; fall back to the error so the "timed
|
# empty) stdout/stderr; fall back to the error so the "timed
|
||||||
# out" reason reaches the UI instead of a blank result.
|
# out" reason reaches the UI instead of a blank result.
|
||||||
raw = result["stdout"] or result["stderr"] or result.get("error", "")
|
raw = result["stdout"] or result["stderr"] or result.get("error", "")
|
||||||
output_text = _truncate(raw)
|
output_text = _truncate(_redact_sensitive_text(raw))
|
||||||
elif "output" in result:
|
elif "output" in result:
|
||||||
# bash / python canonical result: {"output": ..., "exit_code": ...}
|
# bash / python canonical result: {"output": ..., "exit_code": ...}
|
||||||
raw = result["output"] or ""
|
raw = result["output"] or ""
|
||||||
output_text = _truncate(raw)
|
output_text = _truncate(_redact_sensitive_text(raw))
|
||||||
elif "response" in result:
|
elif "response" in result:
|
||||||
# AI interaction tools (chat_with_model, send_to_session)
|
# AI interaction tools (chat_with_model, send_to_session)
|
||||||
label = result.get("model", result.get("session_name", "AI"))
|
label = result.get("model", result.get("session_name", "AI"))
|
||||||
output_text = _truncate(f"{label}: {result['response']}")
|
output_text = _truncate(_redact_sensitive_text(f"{label}: {result['response']}"))
|
||||||
elif "content" in result:
|
elif "content" in result:
|
||||||
output_text = _truncate(result["content"])
|
output_text = _truncate(_redact_sensitive_text(result["content"]))
|
||||||
elif "results" in result:
|
elif "results" in result:
|
||||||
output_text = _truncate(result["results"])
|
output_text = _truncate(_redact_sensitive_text(result["results"]))
|
||||||
elif "session_id" in result and "name" in result:
|
elif "session_id" in result and "name" in result:
|
||||||
output_text = f"Session created: {result['name']} (id: {result['session_id']})"
|
output_text = f"Session created: {result['name']} (id: {result['session_id']})"
|
||||||
elif "success" in result:
|
elif "success" in result:
|
||||||
output_text = (
|
output_text = (
|
||||||
f"Written: {result.get('path', '')}"
|
f"Written: {result.get('path', '')}"
|
||||||
if result["success"]
|
if result["success"]
|
||||||
else f"Error: {result.get('error', '')}"
|
else f"Error: {_redact_sensitive_text(result.get('error', ''))}"
|
||||||
)
|
)
|
||||||
elif "error" in result:
|
elif "error" in result:
|
||||||
output_text = _truncate(result["error"])
|
output_text = _truncate(_redact_sensitive_text(result["error"]))
|
||||||
|
|
||||||
# Emit tool_output (include ui_event data if present)
|
# Emit tool_output (include ui_event data if present)
|
||||||
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
|
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
|
||||||
|
if _pending_ask_user_event:
|
||||||
|
# Keep enough state in the streamed tool result for alternate
|
||||||
|
# clients to render the prompt without depending on event order.
|
||||||
|
tool_output_data["ask_user"] = _pending_ask_user_event
|
||||||
if "ui_event" in result:
|
if "ui_event" in result:
|
||||||
tool_output_data["ui_event"] = result["ui_event"]
|
tool_output_data["ui_event"] = result["ui_event"]
|
||||||
for k in (
|
for k in (
|
||||||
@@ -3319,6 +3533,14 @@ async def stream_agent_loop(
|
|||||||
tool_output_data["diff"] = result["diff"]
|
tool_output_data["diff"] = result["diff"]
|
||||||
yield f'data: {json.dumps(tool_output_data)}\n\n'
|
yield f'data: {json.dumps(tool_output_data)}\n\n'
|
||||||
|
|
||||||
|
# This must be the final UI event for ask_user: the frontend appends
|
||||||
|
# the card below the now-settled tool node and cancels any between-
|
||||||
|
# round spinner. The turn ends after the current tool batch.
|
||||||
|
if _pending_ask_user_event:
|
||||||
|
yield (
|
||||||
|
f'data: {json.dumps({"type": "ask_user", "data": _pending_ask_user_event})}\n\n'
|
||||||
|
)
|
||||||
|
|
||||||
# Native document tools open in the editor + carry the REAL doc id.
|
# Native document tools open in the editor + carry the REAL doc id.
|
||||||
# Emit a doc_update so the frontend opens/activates it and sends it
|
# Emit a doc_update so the frontend opens/activates it and sends it
|
||||||
# back as active_doc_id next turn (otherwise the agent can't "see"
|
# back as active_doc_id next turn (otherwise the agent can't "see"
|
||||||
@@ -3376,6 +3598,11 @@ async def stream_agent_loop(
|
|||||||
# this the diff shows live but vanishes from saved history.
|
# this the diff shows live but vanishes from saved history.
|
||||||
if result.get("diff"):
|
if result.get("diff"):
|
||||||
tool_event["diff"] = result["diff"]
|
tool_event["diff"] = result["diff"]
|
||||||
|
if _pending_ask_user_event:
|
||||||
|
# Persist the structured question with the tool event. On a
|
||||||
|
# reload, chatRenderer can restore the card; a later user
|
||||||
|
# message removes it as answered.
|
||||||
|
tool_event["ask_user"] = _pending_ask_user_event
|
||||||
tool_events.append(tool_event)
|
tool_events.append(tool_event)
|
||||||
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
|
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
|
||||||
_effectful_used = True
|
_effectful_used = True
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocument
|
|||||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||||
from .bg_job_tools import ManageBgJobsTool
|
from .bg_job_tools import ManageBgJobsTool
|
||||||
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
||||||
|
from .admin_tools import (
|
||||||
|
ADMIN_TOOL_HANDLERS,
|
||||||
|
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||||
|
do_manage_tokens, do_manage_settings,
|
||||||
|
)
|
||||||
|
|
||||||
TOOL_HANDLERS = {
|
TOOL_HANDLERS = {
|
||||||
"bash": BashTool().execute,
|
"bash": BashTool().execute,
|
||||||
@@ -52,6 +57,8 @@ TOOL_HANDLERS = {
|
|||||||
"send_to_session": SendToSessionTool().execute,
|
"send_to_session": SendToSessionTool().execute,
|
||||||
"manage_session": ManageSessionTool().execute,
|
"manage_session": ManageSessionTool().execute,
|
||||||
}
|
}
|
||||||
|
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
|
||||||
|
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constants (re-exported for backward compatibility — single source of truth
|
# Constants (re-exported for backward compatibility — single source of truth
|
||||||
@@ -138,10 +145,5 @@ from src.tool_implementations import ( # noqa: E402, F401
|
|||||||
do_search_chats,
|
do_search_chats,
|
||||||
do_manage_skills,
|
do_manage_skills,
|
||||||
do_manage_tasks,
|
do_manage_tasks,
|
||||||
do_manage_endpoints,
|
|
||||||
do_manage_mcp,
|
|
||||||
do_manage_webhooks,
|
|
||||||
do_manage_tokens,
|
|
||||||
do_manage_settings,
|
|
||||||
do_api_call,
|
do_api_call,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,784 @@
|
|||||||
|
"""Config/integration admin agent tools (TOOL_HANDLERS).
|
||||||
|
|
||||||
|
Moved verbatim from tool_implementations.py as part of the tool-registry
|
||||||
|
migration (#3629, the `admin_tools.py` bullet): manage_endpoints / manage_mcp /
|
||||||
|
manage_webhooks / manage_tokens / manage_settings, plus manage_mcp's
|
||||||
|
command-allowlist guard. Each impl keeps its `do_*(content, owner)` shape;
|
||||||
|
ADMIN_TOOL_HANDLERS wraps them into registry `execute(content, ctx)` adapters
|
||||||
|
via one factory.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
from src.tool_utils import get_mcp_manager, _parse_tool_args
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Manage model endpoints: list, add, delete, enable, disable."""
|
||||||
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
|
try:
|
||||||
|
args = _parse_tool_args(content)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
|
action = args.get("action", "list")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if action == "list":
|
||||||
|
eps = db.query(ModelEndpoint).all()
|
||||||
|
items = [{"id": e.id, "name": e.name, "base_url": e.base_url,
|
||||||
|
"is_enabled": e.is_enabled} for e in eps]
|
||||||
|
return {"response": f"{len(items)} endpoints", "endpoints": items, "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "add":
|
||||||
|
import uuid as _uuid
|
||||||
|
name = args.get("name", "")
|
||||||
|
base_url = args.get("base_url", "")
|
||||||
|
api_key = args.get("api_key", "")
|
||||||
|
if not base_url:
|
||||||
|
return {"error": "base_url is required", "exit_code": 1}
|
||||||
|
eid = str(_uuid.uuid4())[:8]
|
||||||
|
from datetime import datetime
|
||||||
|
ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url,
|
||||||
|
api_key=api_key, is_enabled=True,
|
||||||
|
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||||
|
db.add(ep)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Added endpoint '{name or base_url}' (id: {eid})", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
eid = args.get("endpoint_id", "")
|
||||||
|
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
|
||||||
|
if not ep:
|
||||||
|
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
|
||||||
|
name = ep.name
|
||||||
|
db.delete(ep)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Deleted endpoint '{name}'", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action in ("enable", "disable"):
|
||||||
|
eid = args.get("endpoint_id", "")
|
||||||
|
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
|
||||||
|
if not ep:
|
||||||
|
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
|
||||||
|
ep.is_enabled = (action == "enable")
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Endpoint '{ep.name}' {action}d", "exit_code": 0}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"manage_endpoints error: {e}")
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MCP server management tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Parallel to routes/cookbook_helpers._validate_serve_cmd but deliberately the
|
||||||
|
# opposite policy: that gate guards an admin-only serve command and allows
|
||||||
|
# interpreters (python3/etc) because model-serving needs them, whereas this is
|
||||||
|
# the model/prompt-injection-reachable manage_mcp path, so interpreters and
|
||||||
|
# runners are denied here.
|
||||||
|
#
|
||||||
|
# Commands that can execute arbitrary code regardless of their arguments. These
|
||||||
|
# are NEVER accepted on the manage_mcp agent path, even if an operator lists one
|
||||||
|
# in ODYSSEUS_MCP_ALLOWED_COMMANDS -- a stdio server that genuinely needs an
|
||||||
|
# interpreter or package runner must be registered via the trusted admin route.
|
||||||
|
_MCP_DENIED_COMMANDS = frozenset({
|
||||||
|
"sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "ash", "busybox",
|
||||||
|
"cmd", "command.com", "powershell", "pwsh",
|
||||||
|
"python", "pypy", "node", "nodejs", "deno", "bun", "ruby", "jruby",
|
||||||
|
"perl", "raku", "php", "lua", "luajit", "tclsh", "wish", "expect", "rscript",
|
||||||
|
"groovy", "scala", "elixir", "erl", "iex", "java", "javac", "jshell", "jbang",
|
||||||
|
"kotlin", "kotlinc", "dotnet", "mono", "swift", "osascript", "tsx", "ts-node",
|
||||||
|
"npx", "bunx", "uvx", "pipx", "npm", "pnpm", "yarn", "pip", "uv",
|
||||||
|
"gem", "cargo", "go", "bundle", "poetry", "conda", "mamba", "brew",
|
||||||
|
"apt", "apt-get", "yum", "dnf", "pacman", "apk",
|
||||||
|
"env", "xargs", "nohup", "setsid", "nice", "ionice", "time", "timeout",
|
||||||
|
"watch", "stdbuf", "unbuffer", "script", "ssh", "scp", "sshpass", "sudo",
|
||||||
|
"doas", "su", "make", "cmake", "docker", "podman", "kubectl", "find",
|
||||||
|
"awk", "gawk", "sed", "vi", "vim", "nvim", "emacs", "ed", "tee", "eval",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Argv flags that make even an allowlisted binary execute inline code. Matched
|
||||||
|
# by prefix so glued forms (-cimport os, --eval=...) are caught, not just the
|
||||||
|
# exact-token form.
|
||||||
|
_MCP_CODE_EXEC_SHORT_FLAGS = ("-c", "-e", "-m")
|
||||||
|
_MCP_CODE_EXEC_LONG_FLAGS = ("--eval", "--exec", "--print", "--module", "--command", "--require")
|
||||||
|
|
||||||
|
_MCP_URL_SCHEMES = ("http://", "https://", "ftp://", "ftps://", "file://", "data:", "jar:", "blob:")
|
||||||
|
|
||||||
|
# Shell metacharacters refused in command/args. Args are passed as an argv list
|
||||||
|
# (no shell), but refusing these keeps the surface narrow and obvious.
|
||||||
|
_MCP_SHELL_METACHARS = set(";|&$`><\n\r")
|
||||||
|
|
||||||
|
# Env vars that let a child process load attacker-supplied code before main().
|
||||||
|
_MCP_DANGEROUS_ENV = frozenset({
|
||||||
|
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "DYLD_INSERT_LIBRARIES",
|
||||||
|
"DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "PYTHONPATH", "PYTHONSTARTUP",
|
||||||
|
"PYTHONHOME", "PYTHONEXECUTABLE", "NODE_OPTIONS", "NODE_PATH", "BASH_ENV",
|
||||||
|
"ENV", "SHELLOPTS", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "GEM_PATH",
|
||||||
|
"R_PROFILE", "R_HOME", "PATH", "IFS", "PROMPT_COMMAND",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_allowed_commands() -> set:
|
||||||
|
"""Operator-configured allowlist of safe MCP launcher basenames for the agent
|
||||||
|
path. Empty by default; set ODYSSEUS_MCP_ALLOWED_COMMANDS (comma-separated)
|
||||||
|
to opt specific trusted binaries in. Denied commands are rejected even if
|
||||||
|
listed here."""
|
||||||
|
raw = os.environ.get("ODYSSEUS_MCP_ALLOWED_COMMANDS", "")
|
||||||
|
return {c.strip().lower() for c in raw.split(",") if c.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_mcp_command(command, args, env) -> Optional[str]:
|
||||||
|
"""Validate a model-supplied stdio MCP registration. Returns an error string
|
||||||
|
if it must be rejected, else None.
|
||||||
|
|
||||||
|
Closes the RCE where manage_mcp 'add' passed prompt-injection-controlled
|
||||||
|
command/args/env straight to a subprocess spawn (issue #438): a payload
|
||||||
|
smuggled into a skill description, memory entry, fetched page, or email body
|
||||||
|
could register a stdio server running arbitrary code as the app UID.
|
||||||
|
"""
|
||||||
|
if not isinstance(command, str) or not command.strip():
|
||||||
|
return "command must be a non-empty string"
|
||||||
|
command = command.strip()
|
||||||
|
if "/" in command or "\\" in command:
|
||||||
|
return "command must be a bare executable name, not a path"
|
||||||
|
if any(ch in _MCP_SHELL_METACHARS for ch in command):
|
||||||
|
return "command contains shell metacharacters"
|
||||||
|
base = command.lower()
|
||||||
|
if base.endswith(".exe") or base.endswith(".cmd") or base.endswith(".bat"):
|
||||||
|
base = base.rsplit(".", 1)[0]
|
||||||
|
# Canonicalize a trailing version suffix so versioned aliases collapse to the
|
||||||
|
# family name (python3.11 -> python, node18 -> node, pip3 -> pip); both the
|
||||||
|
# raw basename and the canonical form are denied, so an operator cannot
|
||||||
|
# accidentally allowlist a runtime alias back into the path.
|
||||||
|
canon = re.sub(r"[-_.]?\d+(?:\.\d+)*$", "", base)
|
||||||
|
if base in _MCP_DENIED_COMMANDS or canon in _MCP_DENIED_COMMANDS:
|
||||||
|
return (
|
||||||
|
f"command '{command}' is not allowed on the agent MCP path: "
|
||||||
|
"interpreters, runtimes, package runners, and shells can execute "
|
||||||
|
"arbitrary code. Register such a server via the admin route instead."
|
||||||
|
)
|
||||||
|
if base not in _mcp_allowed_commands():
|
||||||
|
return (
|
||||||
|
f"command '{command}' is not in the MCP allowlist. Add it to "
|
||||||
|
"ODYSSEUS_MCP_ALLOWED_COMMANDS if you trust it, or register the "
|
||||||
|
"server via the admin route."
|
||||||
|
)
|
||||||
|
|
||||||
|
if args is not None:
|
||||||
|
if isinstance(args, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(args)
|
||||||
|
except Exception:
|
||||||
|
return "args must be a JSON list"
|
||||||
|
if not isinstance(args, list):
|
||||||
|
return "args must be a list"
|
||||||
|
for a in args:
|
||||||
|
if not isinstance(a, str):
|
||||||
|
return "args must all be strings"
|
||||||
|
s = a.strip()
|
||||||
|
low = s.lower()
|
||||||
|
if any(s == f or s.startswith(f) for f in _MCP_CODE_EXEC_SHORT_FLAGS):
|
||||||
|
return f"arg '{a}' is a code-execution flag and is not allowed"
|
||||||
|
if any(low == f or low.startswith(f + "=") for f in _MCP_CODE_EXEC_LONG_FLAGS):
|
||||||
|
return f"arg '{a}' is a code-execution flag and is not allowed"
|
||||||
|
if any(low.startswith(u) for u in _MCP_URL_SCHEMES):
|
||||||
|
return f"arg '{a}' is a remote URL and is not allowed"
|
||||||
|
if any(ch in _MCP_SHELL_METACHARS for ch in a):
|
||||||
|
return f"arg '{a}' contains shell metacharacters"
|
||||||
|
|
||||||
|
if env:
|
||||||
|
if isinstance(env, str):
|
||||||
|
try:
|
||||||
|
env = json.loads(env)
|
||||||
|
except Exception:
|
||||||
|
return "env must be a JSON object"
|
||||||
|
if not isinstance(env, dict):
|
||||||
|
return "env must be an object"
|
||||||
|
for k in env:
|
||||||
|
if str(k).strip().upper() in _MCP_DANGEROUS_ENV:
|
||||||
|
return f"env var '{k}' can inject code into the child process and is not allowed"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def do_manage_mcp(content: str, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Manage MCP servers: list, add, delete, enable, disable, reconnect."""
|
||||||
|
try:
|
||||||
|
args = _parse_tool_args(content)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
|
action = args.get("action", "list")
|
||||||
|
|
||||||
|
if action == "list":
|
||||||
|
mcp = get_mcp_manager()
|
||||||
|
if not mcp:
|
||||||
|
return {"response": "No MCP manager available", "servers": [], "exit_code": 0}
|
||||||
|
from core.database import SessionLocal, McpServer
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
servers = db.query(McpServer).all()
|
||||||
|
items = []
|
||||||
|
for s in servers:
|
||||||
|
st = mcp.get_server_status(s.id)
|
||||||
|
status = st.get("status", "disconnected")
|
||||||
|
tool_count = st.get("tool_count", 0)
|
||||||
|
items.append({"id": s.id, "name": s.name, "transport": s.transport,
|
||||||
|
"is_enabled": s.is_enabled, "status": status,
|
||||||
|
"tool_count": tool_count})
|
||||||
|
return {"response": f"{len(items)} MCP servers", "servers": items, "exit_code": 0}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
elif action == "add":
|
||||||
|
from core.database import SessionLocal, McpServer
|
||||||
|
import uuid as _uuid
|
||||||
|
from datetime import datetime
|
||||||
|
name = args.get("name", "")
|
||||||
|
command = args.get("command", "")
|
||||||
|
cmd_args = args.get("args", [])
|
||||||
|
env = args.get("env", {})
|
||||||
|
if not name or not command:
|
||||||
|
return {"error": "name and command are required", "exit_code": 1}
|
||||||
|
# Validate BEFORE any DB write or spawn: a rejected registration must
|
||||||
|
# leave no enabled row (which would otherwise auto-reconnect on restart)
|
||||||
|
# and must not attempt a connection.
|
||||||
|
_mcp_err = _validate_mcp_command(command, cmd_args, env)
|
||||||
|
if _mcp_err:
|
||||||
|
return {"error": f"manage_mcp: refused unsafe server registration: {_mcp_err}", "exit_code": 1}
|
||||||
|
sid = str(_uuid.uuid4())[:8]
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = McpServer(id=sid, name=name, transport="stdio", command=command,
|
||||||
|
args=json.dumps(cmd_args) if isinstance(cmd_args, list) else cmd_args,
|
||||||
|
env=json.dumps(env) if isinstance(env, dict) else env,
|
||||||
|
is_enabled=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||||
|
db.add(srv)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
# Try to connect
|
||||||
|
mcp = get_mcp_manager()
|
||||||
|
tool_count = 0
|
||||||
|
if mcp:
|
||||||
|
try:
|
||||||
|
await mcp.connect_server(
|
||||||
|
sid, name, "stdio", command=command,
|
||||||
|
args=cmd_args if isinstance(cmd_args, list) else json.loads(cmd_args),
|
||||||
|
env=env if isinstance(env, dict) else json.loads(env),
|
||||||
|
)
|
||||||
|
st = mcp.get_server_status(sid)
|
||||||
|
tool_count = st.get("tool_count", 0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"MCP connect failed for {name}: {e}")
|
||||||
|
return {"response": f"Added MCP server '{name}' ({tool_count} tools)", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
sid = args.get("server_id", "")
|
||||||
|
from core.database import SessionLocal, McpServer
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db.query(McpServer).filter(McpServer.id == sid).first()
|
||||||
|
if not srv:
|
||||||
|
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||||
|
name = srv.name
|
||||||
|
mcp = get_mcp_manager()
|
||||||
|
if mcp:
|
||||||
|
try:
|
||||||
|
await mcp.disconnect_server(sid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
db.delete(srv)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Deleted MCP server '{name}'", "exit_code": 0}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
elif action == "reconnect":
|
||||||
|
sid = args.get("server_id", "")
|
||||||
|
mcp = get_mcp_manager()
|
||||||
|
if not mcp:
|
||||||
|
return {"error": "MCP manager not available", "exit_code": 1}
|
||||||
|
try:
|
||||||
|
await mcp.disconnect_server(sid)
|
||||||
|
from core.database import SessionLocal, McpServer
|
||||||
|
db2 = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db2.query(McpServer).filter(McpServer.id == sid).first()
|
||||||
|
if srv:
|
||||||
|
_args = json.loads(srv.args) if srv.args else []
|
||||||
|
_env = json.loads(srv.env) if srv.env else {}
|
||||||
|
await mcp.connect_server(
|
||||||
|
server_id=sid,
|
||||||
|
name=srv.name,
|
||||||
|
transport=srv.transport,
|
||||||
|
command=srv.command,
|
||||||
|
args=_args,
|
||||||
|
env=_env,
|
||||||
|
url=srv.url,
|
||||||
|
)
|
||||||
|
st = mcp.get_server_status(sid)
|
||||||
|
return {"response": f"Reconnected '{srv.name}' ({st.get('tool_count', 0)} tools)", "exit_code": 0}
|
||||||
|
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||||
|
finally:
|
||||||
|
db2.close()
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
|
||||||
|
elif action in ("enable", "disable"):
|
||||||
|
sid = args.get("server_id", "")
|
||||||
|
from core.database import SessionLocal, McpServer
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db.query(McpServer).filter(McpServer.id == sid).first()
|
||||||
|
if not srv:
|
||||||
|
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||||
|
srv.is_enabled = (action == "enable")
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"MCP server '{srv.name}' {action}d", "exit_code": 0}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
elif action == "list_tools":
|
||||||
|
mcp = get_mcp_manager()
|
||||||
|
if not mcp:
|
||||||
|
return {"response": "No MCP manager", "tools": [], "exit_code": 0}
|
||||||
|
tools = mcp.get_all_tools()
|
||||||
|
items = [{"name": t["name"], "server": t["server_name"],
|
||||||
|
"description": t.get("description", "")[:100]} for t in tools]
|
||||||
|
return {"response": f"{len(items)} MCP tools available", "tools": items, "exit_code": 0}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Webhook management tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def do_manage_webhooks(content: str, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Manage webhooks: list, add, delete, enable, disable, test."""
|
||||||
|
from core.database import SessionLocal
|
||||||
|
try:
|
||||||
|
args = _parse_tool_args(content)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
|
action = args.get("action", "list")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from core.database import Webhook
|
||||||
|
if action == "list":
|
||||||
|
hooks = db.query(Webhook).all()
|
||||||
|
items = [{"id": h.id, "name": h.name, "url": h.url,
|
||||||
|
"events": h.events, "is_active": h.is_active} for h in hooks]
|
||||||
|
return {"response": f"{len(items)} webhooks", "webhooks": items, "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "add":
|
||||||
|
import uuid as _uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from src.webhook_manager import validate_events, validate_webhook_url
|
||||||
|
name = args.get("name", "")
|
||||||
|
url = args.get("url", "")
|
||||||
|
events = args.get("events", "chat.completed")
|
||||||
|
if not url:
|
||||||
|
return {"error": "url is required", "exit_code": 1}
|
||||||
|
try:
|
||||||
|
url = validate_webhook_url(url)
|
||||||
|
events = validate_events(events)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
wid = str(_uuid.uuid4())[:8]
|
||||||
|
hook = Webhook(id=wid, name=name or url, url=url,
|
||||||
|
events=events, is_active=True,
|
||||||
|
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||||
|
db.add(hook)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Added webhook '{name or url}'", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
wid = args.get("webhook_id", "")
|
||||||
|
hook = db.query(Webhook).filter(Webhook.id == wid).first()
|
||||||
|
if not hook:
|
||||||
|
return {"error": f"Webhook {wid} not found", "exit_code": 1}
|
||||||
|
name = hook.name
|
||||||
|
db.delete(hook)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Deleted webhook '{name}'", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action in ("enable", "disable"):
|
||||||
|
wid = args.get("webhook_id", "")
|
||||||
|
hook = db.query(Webhook).filter(Webhook.id == wid).first()
|
||||||
|
if not hook:
|
||||||
|
return {"error": f"Webhook {wid} not found", "exit_code": 1}
|
||||||
|
hook.is_active = (action == "enable")
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Webhook '{hook.name}' {action}d", "exit_code": 0}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"manage_webhooks error: {e}")
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API token management tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def do_manage_tokens(content: str, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Manage API tokens: list, create, delete."""
|
||||||
|
from core.database import SessionLocal, ApiToken
|
||||||
|
try:
|
||||||
|
args = _parse_tool_args(content)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
|
action = args.get("action", "list")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if action == "list":
|
||||||
|
tokens = db.query(ApiToken).all()
|
||||||
|
items = [{"id": t.id, "name": t.name, "token_prefix": t.token_prefix + "...",
|
||||||
|
"is_active": t.is_active} for t in tokens]
|
||||||
|
return {"response": f"{len(items)} API tokens", "tokens": items, "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "create":
|
||||||
|
import uuid as _uuid, secrets, bcrypt
|
||||||
|
from datetime import datetime
|
||||||
|
name = args.get("name", "API Token")
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
||||||
|
tid = str(_uuid.uuid4())[:8]
|
||||||
|
t = ApiToken(id=tid, name=name, token_hash=token_hash,
|
||||||
|
token_prefix=raw_token[:8], is_active=True,
|
||||||
|
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||||
|
db.add(t)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Created token '{name}'", "token": raw_token, "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
tid = args.get("token_id", "")
|
||||||
|
t = db.query(ApiToken).filter(ApiToken.id == tid).first()
|
||||||
|
if not t:
|
||||||
|
return {"error": f"Token {tid} not found", "exit_code": 1}
|
||||||
|
name = t.name
|
||||||
|
db.delete(t)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Deleted token '{name}'", "exit_code": 0}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"manage_tokens error: {e}")
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings/preferences management tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Manage user settings and preferences."""
|
||||||
|
try:
|
||||||
|
args = _parse_tool_args(content)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
|
action = args.get("action", "list")
|
||||||
|
|
||||||
|
from core.database import SessionLocal
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# set/get/list/delete operate on the REAL app settings (the same store
|
||||||
|
# the Settings panel writes), so changing a model / voice / search
|
||||||
|
# engine / reminder channel from chat actually takes effect.
|
||||||
|
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
|
||||||
|
|
||||||
|
# Secrets/credentials the agent must NOT write: kept read-only (masked)
|
||||||
|
# so API keys never flow through chat. User sets these in the panel.
|
||||||
|
_SECRET_KEYS = {
|
||||||
|
"brave_api_key", "google_pse_key", "google_pse_cx",
|
||||||
|
"tavily_api_key", "serper_api_key", "app_public_url",
|
||||||
|
}
|
||||||
|
def _is_secret(k):
|
||||||
|
# `token` must be a suffix, not a substring: otherwise the int
|
||||||
|
# setting `agent_input_token_budget` (which even has a "token budget"
|
||||||
|
# alias to set it from chat) is wrongly classified as a credential.
|
||||||
|
return (
|
||||||
|
k in _SECRET_KEYS
|
||||||
|
or k.endswith("token")
|
||||||
|
or any(t in k for t in ("api_key", "_key", "secret", "password"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Friendly aliases → real keys, so natural phrasing resolves.
|
||||||
|
_ALIASES_SET = {
|
||||||
|
"voice": "tts_voice", "tts voice": "tts_voice", "tts": "tts_enabled",
|
||||||
|
"text to speech": "tts_enabled", "tts provider": "tts_provider",
|
||||||
|
"speech speed": "tts_speed", "voice speed": "tts_speed",
|
||||||
|
"stt": "stt_enabled", "speech to text": "stt_enabled", "transcription": "stt_enabled",
|
||||||
|
"search engine": "search_provider", "search provider": "search_provider",
|
||||||
|
"search results": "search_result_count", "result count": "search_result_count",
|
||||||
|
"default model": "default_model", "chat model": "default_model",
|
||||||
|
"default endpoint": "default_endpoint_id",
|
||||||
|
"task model": "task_model", "background model": "task_model",
|
||||||
|
"teacher model": "teacher_model", "teacher": "teacher_enabled",
|
||||||
|
"utility model": "utility_model", "research model": "research_model",
|
||||||
|
"research max tokens": "research_max_tokens",
|
||||||
|
"vision model": "vision_model", "vision": "vision_enabled",
|
||||||
|
"image model": "image_model", "image quality": "image_quality",
|
||||||
|
"image gen": "image_gen_enabled", "image generation": "image_gen_enabled",
|
||||||
|
"reminder channel": "reminder_channel", "reminders": "reminder_channel",
|
||||||
|
"ntfy topic": "reminder_ntfy_topic",
|
||||||
|
"webhook integration": "reminder_webhook_integration_id",
|
||||||
|
"webhook template": "reminder_webhook_payload_template", "webhook payload": "reminder_webhook_payload_template",
|
||||||
|
"agent tool calls": "agent_max_tool_calls", "max tool calls": "agent_max_tool_calls",
|
||||||
|
"agent timeout": "agent_stream_timeout_seconds", "stream timeout": "agent_stream_timeout_seconds",
|
||||||
|
"token budget": "agent_input_token_budget", "input budget": "agent_input_token_budget",
|
||||||
|
"hard max": "agent_input_token_hard_max",
|
||||||
|
"token budget cap": "agent_input_token_hard_max",
|
||||||
|
"input budget cap": "agent_input_token_hard_max",
|
||||||
|
}
|
||||||
|
def _resolve(k):
|
||||||
|
k2 = (k or "").strip().lower()
|
||||||
|
if k2 in DEFAULT_SETTINGS:
|
||||||
|
return k2
|
||||||
|
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||||
|
|
||||||
|
_ENUMS = {
|
||||||
|
"image_quality": ["low", "medium", "high"],
|
||||||
|
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
|
||||||
|
}
|
||||||
|
def _coerce(value, default):
|
||||||
|
if isinstance(default, bool):
|
||||||
|
return value if isinstance(value, bool) else str(value).strip().lower() in ("true", "on", "yes", "1", "enable", "enabled")
|
||||||
|
if isinstance(default, int):
|
||||||
|
return int(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _model_slug(value: str) -> str:
|
||||||
|
import re as _re
|
||||||
|
return _re.sub(r"[^a-z0-9]+", "", (value or "").lower())
|
||||||
|
|
||||||
|
def _endpoint_model_from_cache(model_query: str):
|
||||||
|
"""Resolve friendly model text to an enabled endpoint + real model id.
|
||||||
|
|
||||||
|
The Settings UI stores both `<prefix>_endpoint_id` and
|
||||||
|
`<prefix>_model`; writing only the model leaves the runtime on the
|
||||||
|
old endpoint. Prefer cached model lists so this stays fast/offline.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
import re as _re
|
||||||
|
from core.database import ModelEndpoint
|
||||||
|
|
||||||
|
wanted = (model_query or "").strip()
|
||||||
|
wanted_slug = _model_slug(wanted)
|
||||||
|
wanted_tokens = [_model_slug(t) for t in _re.findall(r"[A-Za-z0-9]+", wanted)]
|
||||||
|
wanted_tokens = [t for t in wanted_tokens if t]
|
||||||
|
if not wanted_slug:
|
||||||
|
return None
|
||||||
|
best = None
|
||||||
|
for ep in db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all():
|
||||||
|
raw_models = []
|
||||||
|
try:
|
||||||
|
raw_models = _json.loads(ep.cached_models or "[]") or []
|
||||||
|
except Exception:
|
||||||
|
raw_models = []
|
||||||
|
# If cache is empty, still allow matching against endpoint name
|
||||||
|
# for callers using model@endpoint elsewhere later.
|
||||||
|
for mid in raw_models:
|
||||||
|
mid = str(mid)
|
||||||
|
mid_slug = _model_slug(mid)
|
||||||
|
if not mid_slug:
|
||||||
|
continue
|
||||||
|
exact = mid.lower() == wanted.lower()
|
||||||
|
compact_match = wanted_slug in mid_slug or mid_slug in wanted_slug
|
||||||
|
token_match = bool(wanted_tokens) and all(tok in mid_slug for tok in wanted_tokens)
|
||||||
|
if exact or compact_match or token_match:
|
||||||
|
score = 3 if exact else (2 if compact_match else 1)
|
||||||
|
if not best or score > best[0]:
|
||||||
|
best = (score, ep.id, mid)
|
||||||
|
if best:
|
||||||
|
return {"endpoint_id": best[1], "model": best[2]}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _mask(k, v):
|
||||||
|
return "••••• (set in panel)" if _is_secret(k) and v else v
|
||||||
|
|
||||||
|
if action == "list":
|
||||||
|
s = load_settings()
|
||||||
|
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
|
||||||
|
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "get":
|
||||||
|
key = _resolve(args.get("key", ""))
|
||||||
|
if not key:
|
||||||
|
return {"error": "key is required", "exit_code": 1}
|
||||||
|
if key not in DEFAULT_SETTINGS:
|
||||||
|
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
|
||||||
|
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
|
||||||
|
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "set":
|
||||||
|
raw = args.get("key", "")
|
||||||
|
value = args.get("value")
|
||||||
|
if not raw:
|
||||||
|
return {"error": "key is required", "exit_code": 1}
|
||||||
|
key = _resolve(raw)
|
||||||
|
if key not in DEFAULT_SETTINGS:
|
||||||
|
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
|
||||||
|
if _is_secret(key):
|
||||||
|
return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
|
||||||
|
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
|
||||||
|
# have no safe scalar coercion; _coerce would pass a bare string
|
||||||
|
# straight through and clobber the structure. Refuse them here; they're
|
||||||
|
# edited in their dedicated panels. (reset/delete still restore the
|
||||||
|
# default structure, which is safe.)
|
||||||
|
if isinstance(DEFAULT_SETTINGS[key], (dict, list)):
|
||||||
|
return {"response": f"'{key}' is a structured setting. Edit it in its panel, not from chat. (You can reset it to default here.)", "exit_code": 0}
|
||||||
|
try:
|
||||||
|
value = _coerce(value, DEFAULT_SETTINGS[key])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {"error": f"'{value}' isn't a valid value for {key} (expected {type(DEFAULT_SETTINGS[key]).__name__}).", "exit_code": 1}
|
||||||
|
if key in _ENUMS and str(value).lower() not in _ENUMS[key]:
|
||||||
|
return {"error": f"{key} must be one of: {', '.join(_ENUMS[key])}.", "exit_code": 1}
|
||||||
|
s = load_settings()
|
||||||
|
s[key] = value
|
||||||
|
if key in {"default_model", "research_model", "utility_model", "task_model", "vision_model", "image_model"}:
|
||||||
|
resolved = _endpoint_model_from_cache(str(value))
|
||||||
|
if resolved:
|
||||||
|
prefix = key[:-6]
|
||||||
|
s[f"{prefix}_endpoint_id"] = resolved["endpoint_id"]
|
||||||
|
s[key] = resolved["model"]
|
||||||
|
value = resolved["model"]
|
||||||
|
save_settings(s)
|
||||||
|
if key.endswith("_model") and s.get(f"{key[:-6]}_endpoint_id"):
|
||||||
|
return {"response": f"Set {key} = {value} (endpoint {s.get(f'{key[:-6]}_endpoint_id')}).", "exit_code": 0}
|
||||||
|
return {"response": f"Set {key} = {value}.", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "delete" or action == "reset":
|
||||||
|
key = _resolve(args.get("key", ""))
|
||||||
|
if key not in DEFAULT_SETTINGS:
|
||||||
|
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
|
||||||
|
if _is_secret(key):
|
||||||
|
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
|
||||||
|
s = load_settings()
|
||||||
|
s[key] = DEFAULT_SETTINGS[key]
|
||||||
|
save_settings(s)
|
||||||
|
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action in ("disable_tool", "enable_tool", "list_tools"):
|
||||||
|
# Tool-toggle actions. These edit settings.json:disabled_tools
|
||||||
|
# (the global list read on every chat request) rather than
|
||||||
|
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
||||||
|
# "search" -> "web_search", "browser" -> "builtin_browser",
|
||||||
|
# "documents" -> the document tool set, "memory" ->
|
||||||
|
# manage_memory, etc.
|
||||||
|
from src.settings import get_setting, save_settings, load_settings
|
||||||
|
_ALIASES = {
|
||||||
|
"shell": ["bash"],
|
||||||
|
"terminal": ["bash"],
|
||||||
|
"search": ["web_search", "web_fetch"],
|
||||||
|
"web": ["web_search", "web_fetch"],
|
||||||
|
"browser": ["builtin_browser"],
|
||||||
|
"documents": ["create_document", "edit_document", "update_document", "suggest_document"],
|
||||||
|
"doc": ["create_document", "edit_document", "update_document", "suggest_document"],
|
||||||
|
"memory": ["manage_memory"],
|
||||||
|
"skills": ["manage_skills"],
|
||||||
|
"images": ["generate_image"],
|
||||||
|
"image": ["generate_image"],
|
||||||
|
"tasks": ["manage_tasks"],
|
||||||
|
"notes": ["manage_notes"],
|
||||||
|
"calendar": ["manage_calendar"],
|
||||||
|
"email": ["mcp__email__list_emails", "mcp__email__read_email", "mcp__email__send_email"],
|
||||||
|
"research": ["web_search", "web_fetch"], # research is a per-request flag, not a tool (closest analog)
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "list_tools":
|
||||||
|
current = get_setting("disabled_tools", []) or []
|
||||||
|
return {
|
||||||
|
"response": (
|
||||||
|
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
||||||
|
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
||||||
|
"memory, skills, images, tasks, notes, calendar, email."
|
||||||
|
),
|
||||||
|
"disabled": list(current),
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
|
||||||
|
if not tool_name:
|
||||||
|
return {"error": "tool name required (e.g. 'shell', 'search', 'bash')", "exit_code": 1}
|
||||||
|
targets = _ALIASES.get(tool_name, [tool_name])
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
current = list(settings.get("disabled_tools") or [])
|
||||||
|
before = set(current)
|
||||||
|
if action == "disable_tool":
|
||||||
|
for t in targets:
|
||||||
|
if t not in current:
|
||||||
|
current.append(t)
|
||||||
|
else: # enable_tool
|
||||||
|
current = [t for t in current if t not in targets]
|
||||||
|
after = set(current)
|
||||||
|
settings["disabled_tools"] = current
|
||||||
|
save_settings(settings)
|
||||||
|
|
||||||
|
verb = "Disabled" if action == "disable_tool" else "Enabled"
|
||||||
|
changed = sorted(after.symmetric_difference(before))
|
||||||
|
return {
|
||||||
|
"response": (
|
||||||
|
f"{verb} {tool_name} ({', '.join(targets)}). "
|
||||||
|
f"Now disabled: {', '.join(current) if current else '(none)'}."
|
||||||
|
),
|
||||||
|
"changed": changed,
|
||||||
|
"disabled": list(current),
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"manage_settings error: {e}")
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API call tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── registry adapters ────────────────────────────────────────────────────────
|
||||||
|
def _owner_adapter(fn):
|
||||||
|
"""Wrap a do_*(content, owner) impl as a registry execute(content, ctx)."""
|
||||||
|
async def _execute(content: str, ctx: dict) -> dict:
|
||||||
|
return await fn(content, ctx.get("owner"))
|
||||||
|
return _execute
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_TOOL_HANDLERS = {
|
||||||
|
"manage_endpoints": _owner_adapter(do_manage_endpoints),
|
||||||
|
"manage_mcp": _owner_adapter(do_manage_mcp),
|
||||||
|
"manage_webhooks": _owner_adapter(do_manage_webhooks),
|
||||||
|
"manage_tokens": _owner_adapter(do_manage_tokens),
|
||||||
|
"manage_settings": _owner_adapter(do_manage_settings),
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import json
|
|
||||||
from src.constants import MAX_READ_CHARS
|
from src.constants import MAX_READ_CHARS
|
||||||
|
from src.tool_utils import _parse_tool_args
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -154,38 +154,6 @@ def _coerce_email_document_content(existing: str, incoming: str) -> str:
|
|||||||
body = new
|
body = new
|
||||||
return header.rstrip() + "\n---\n" + body
|
return header.rstrip() + "\n---\n" + body
|
||||||
|
|
||||||
def _parse_tool_args(content):
|
|
||||||
"""Parse a tool-call argument blob.
|
|
||||||
|
|
||||||
Accepts either a JSON string or an already-decoded dict. Unwraps the
|
|
||||||
common `{"body": {...}}` envelope that smaller models emit when they
|
|
||||||
read tool descriptions like "Body is JSON: {...}" literally — they
|
|
||||||
pass `body` as a field name rather than treating it as a noun.
|
|
||||||
|
|
||||||
Returns a dict on success, raises ValueError on bad JSON.
|
|
||||||
"""
|
|
||||||
if isinstance(content, str):
|
|
||||||
try:
|
|
||||||
args = json.loads(content) if content.strip() else {}
|
|
||||||
except (json.JSONDecodeError, TypeError) as e:
|
|
||||||
raise ValueError(str(e))
|
|
||||||
elif isinstance(content, dict):
|
|
||||||
args = content
|
|
||||||
else:
|
|
||||||
args = {}
|
|
||||||
# Unwrap {"body": {...}} envelope — but only if `body` is the sole key
|
|
||||||
# and points at a dict. We don't want to clobber a legitimate `body`
|
|
||||||
# field on tools where it's a real arg (e.g. send_email body text).
|
|
||||||
if (
|
|
||||||
isinstance(args, dict)
|
|
||||||
and len(args) == 1
|
|
||||||
and "body" in args
|
|
||||||
and isinstance(args["body"], dict)
|
|
||||||
and "action" in args["body"] # extra safety: only unwrap if the inner dict looks like a tool call
|
|
||||||
):
|
|
||||||
args = args["body"]
|
|
||||||
return args
|
|
||||||
|
|
||||||
def parse_edit_blocks(content: str) -> list:
|
def parse_edit_blocks(content: str) -> list:
|
||||||
"""Parse <<<FIND>>>...<<<REPLACE>>>...<<<END>>> blocks."""
|
"""Parse <<<FIND>>>...<<<REPLACE>>>...<<<END>>> blocks."""
|
||||||
edits = []
|
edits = []
|
||||||
|
|||||||
+16
-1
@@ -81,11 +81,26 @@ class APIKeyManager:
|
|||||||
keys stay encrypted. Loading via load() first would decrypt them and
|
keys stay encrypted. Loading via load() first would decrypt them and
|
||||||
write them back as plaintext, which then fails to decrypt on the next
|
write them back as plaintext, which then fails to decrypt on the next
|
||||||
load() and silently drops those providers.
|
load() and silently drops those providers.
|
||||||
|
|
||||||
|
Uses atomic write (temp file + os.replace) so a crash, disk-full, or
|
||||||
|
mid-write error never truncates the existing keys file.
|
||||||
"""
|
"""
|
||||||
keys = self._load_raw()
|
keys = self._load_raw()
|
||||||
keys[provider] = self.encrypt_api_key(api_key)
|
keys[provider] = self.encrypt_api_key(api_key)
|
||||||
with open(self.api_keys_file, 'w', encoding="utf-8") as f:
|
tmp_file = self.api_keys_file + ".tmp"
|
||||||
|
try:
|
||||||
|
with open(tmp_file, 'w', encoding="utf-8") as f:
|
||||||
json.dump(keys, f)
|
json.dump(keys, f)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp_file, self.api_keys_file)
|
||||||
|
except OSError:
|
||||||
|
# Clean up temp file on failure; re-raise so callers see the error
|
||||||
|
try:
|
||||||
|
os.remove(tmp_file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
def load(self) -> Dict[str, str]:
|
def load(self) -> Dict[str, str]:
|
||||||
"""Load and decrypt API keys"""
|
"""Load and decrypt API keys"""
|
||||||
|
|||||||
+30
-1
@@ -1,6 +1,13 @@
|
|||||||
# src/app_helpers.py
|
# src/app_helpers.py
|
||||||
import os
|
|
||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def read_if_exists(path: str) -> str:
|
def read_if_exists(path: str) -> str:
|
||||||
"""Read file if it exists, return empty string otherwise."""
|
"""Read file if it exists, return empty string otherwise."""
|
||||||
@@ -20,6 +27,28 @@ def abs_join(base_dir: str, rel: str) -> str:
|
|||||||
"""Join paths and return absolute path."""
|
"""Join paths and return absolute path."""
|
||||||
return os.path.abspath(os.path.join(base_dir, rel))
|
return os.path.abspath(os.path.join(base_dir, rel))
|
||||||
|
|
||||||
|
def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
|
||||||
|
"""Read an app-bundled HTML page and inject the CSP nonce into inline <script> tags.
|
||||||
|
|
||||||
|
Callers pass fixed, server-owned template paths (index/login/backgrounds),
|
||||||
|
never a client-supplied path. So any read failure here — a missing file
|
||||||
|
(broken deployment) or a permission/IO error — is a server fault, not a
|
||||||
|
client "not found": map all of them to a logged 500 so a missing core
|
||||||
|
template surfaces in 5xx alerting instead of hiding behind a 404. If a
|
||||||
|
future caller serves a client-influenced path where 404 is correct, branch
|
||||||
|
that at the call site rather than defaulting this shared helper to 404.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
html = f.read()
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Failed to read page %s", file_path)
|
||||||
|
raise HTTPException(500, "Internal server error")
|
||||||
|
nonce = getattr(request.state, "csp_nonce", "")
|
||||||
|
html = html.replace("{{CSP_NONCE}}", nonce)
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
def inside_base_dir(base_dir: str, path: str) -> bool:
|
def inside_base_dir(base_dir: str, path: str) -> bool:
|
||||||
"""Check if path is inside base directory."""
|
"""Check if path is inside base directory."""
|
||||||
if not isinstance(base_dir, str) or not isinstance(path, str):
|
if not isinstance(base_dir, str) or not isinstance(path, str):
|
||||||
|
|||||||
+19
-26
@@ -1,29 +1,22 @@
|
|||||||
# src/exceptions.py
|
# src/exceptions.py
|
||||||
"""Custom exceptions for the application."""
|
"""Backward-compatible shim — the single source of truth is core/exceptions.py.
|
||||||
|
|
||||||
class SessionNotFoundError(Exception):
|
Historically this module was a byte-for-byte duplicate of core/exceptions.py,
|
||||||
"""Raised when a requested session is not found."""
|
which is the canonical definition (imported by app.py, core/__init__.py, and
|
||||||
def __init__(self, session_id: str):
|
routes/chat_routes.py). To kill the drift, this now simply re-exports the
|
||||||
self.session_id = session_id
|
exception classes from core.exceptions so there is exactly one place that
|
||||||
super().__init__(f"Session '{session_id}' not found")
|
defines them. Existing `from src.exceptions import ...` callers keep working.
|
||||||
|
"""
|
||||||
|
from core.exceptions import ( # noqa: F401
|
||||||
|
SessionNotFoundError,
|
||||||
|
InvalidFileUploadError,
|
||||||
|
LLMServiceError,
|
||||||
|
WebSearchError,
|
||||||
|
)
|
||||||
|
|
||||||
class InvalidFileUploadError(Exception):
|
__all__ = [
|
||||||
"""Raised when a file upload fails validation."""
|
"SessionNotFoundError",
|
||||||
def __init__(self, message: str, filename: str = None):
|
"InvalidFileUploadError",
|
||||||
self.filename = filename
|
"LLMServiceError",
|
||||||
self.message = message
|
"WebSearchError",
|
||||||
super().__init__(message)
|
]
|
||||||
|
|
||||||
class LLMServiceError(Exception):
|
|
||||||
"""Raised when there is an error communicating with the LLM service."""
|
|
||||||
def __init__(self, message: str, endpoint: str = None):
|
|
||||||
self.endpoint = endpoint
|
|
||||||
self.message = message
|
|
||||||
super().__init__(message)
|
|
||||||
|
|
||||||
class WebSearchError(Exception):
|
|
||||||
"""Raised when there is an error with web search functionality."""
|
|
||||||
def __init__(self, message: str, query: str = None):
|
|
||||||
self.query = query
|
|
||||||
self.message = message
|
|
||||||
super().__init__(message)
|
|
||||||
|
|||||||
+150
-16
@@ -345,24 +345,41 @@ def _normalize_ollama_url(url: str) -> str:
|
|||||||
return base.rstrip("/") + "/chat"
|
return base.rstrip("/") + "/chat"
|
||||||
|
|
||||||
|
|
||||||
def _ollama_normalize_tool_messages(messages: List[Dict]) -> List[Dict]:
|
def _ollama_normalize_messages(messages: List[Dict]) -> List[Dict]:
|
||||||
"""Adapt Odysseus' canonical OpenAI-style messages to native Ollama /api/chat.
|
"""Adapt Odysseus' canonical OpenAI-style messages to native Ollama /api/chat.
|
||||||
|
|
||||||
Odysseus carries assistant tool calls in the OpenAI shape, where
|
Two shape mismatches silently break requests:
|
||||||
`function.arguments` is a JSON *string*. Native Ollama expects it to be a
|
|
||||||
JSON *object*; given the string it fails the whole request with HTTP 400
|
1. Tool calls: Odysseus carries `function.arguments` as a JSON *string*.
|
||||||
"Value looks like object, but can't find closing '}' symbol", which aborts
|
Native Ollama expects a JSON *object* and rejects the string form with
|
||||||
every follow-up (tool-result) round. Parse the arguments back into an object
|
HTTP 400 ("Value looks like object, but can't find closing '}' symbol"),
|
||||||
here, on a shallow copy, leaving non-tool messages untouched. The opaque
|
aborting every follow-up (tool-result) round. Parse the arguments back
|
||||||
Gemini `extra_content` (thought_signature) is dropped — it is meaningless to
|
into an object here, on a shallow copy, leaving non-tool messages
|
||||||
Ollama and only matters when the conversation is replayed to Gemini.
|
untouched. The opaque Gemini `extra_content` (thought_signature) is
|
||||||
|
dropped — it is meaningless to Ollama and only matters when the
|
||||||
|
conversation is replayed to Gemini.
|
||||||
|
|
||||||
|
2. Images (issue #4723): Odysseus carries multimodal user content as an
|
||||||
|
OpenAI-style list ``[{type: "text", ...}, {type: "image_url",
|
||||||
|
image_url: {url: "data:image/...;base64,XXX"}}, ...]``. Native Ollama
|
||||||
|
does not accept a list for ``content`` — it wants ``content`` as a
|
||||||
|
string plus a separate ``images`` array of raw base64 strings (no
|
||||||
|
``data:`` prefix). Without this conversion the image blocks pass
|
||||||
|
through untouched, the vision-capable model never sees the picture,
|
||||||
|
and the user gets "I can't see any image" even though the request
|
||||||
|
succeeded.
|
||||||
"""
|
"""
|
||||||
out: List[Dict] = []
|
out: List[Dict] = []
|
||||||
for m in messages or []:
|
for m in messages or []:
|
||||||
tcs = m.get("tool_calls") if isinstance(m, dict) else None
|
if not isinstance(m, dict):
|
||||||
if not tcs:
|
|
||||||
out.append(m)
|
out.append(m)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
nm = dict(m)
|
||||||
|
|
||||||
|
# 1. Tool-call argument strings -> objects.
|
||||||
|
tcs = nm.get("tool_calls")
|
||||||
|
if tcs:
|
||||||
new_calls = []
|
new_calls = []
|
||||||
for tc in tcs:
|
for tc in tcs:
|
||||||
fn = tc.get("function") or {}
|
fn = tc.get("function") or {}
|
||||||
@@ -376,12 +393,54 @@ def _ollama_normalize_tool_messages(messages: List[Dict]) -> List[Dict]:
|
|||||||
if tc.get("id"):
|
if tc.get("id"):
|
||||||
call["id"] = tc["id"]
|
call["id"] = tc["id"]
|
||||||
new_calls.append(call)
|
new_calls.append(call)
|
||||||
nm = dict(m)
|
|
||||||
nm["tool_calls"] = new_calls
|
nm["tool_calls"] = new_calls
|
||||||
|
|
||||||
|
# 2. Multimodal content list -> native content string + images array.
|
||||||
|
content = nm.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
text_parts: List[str] = []
|
||||||
|
images: List[str] = list(nm.get("images") or [])
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
btype = block.get("type")
|
||||||
|
if btype == "text":
|
||||||
|
t = block.get("text")
|
||||||
|
if t:
|
||||||
|
text_parts.append(str(t))
|
||||||
|
elif btype == "image_url":
|
||||||
|
url = (block.get("image_url") or {}).get("url", "")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
if url.startswith("data:"):
|
||||||
|
# Strip the ``data:[...];base64,`` prefix — native
|
||||||
|
# Ollama wants only the base64 bytes.
|
||||||
|
_, _, b64 = url.partition(",")
|
||||||
|
if b64:
|
||||||
|
images.append(b64)
|
||||||
|
else:
|
||||||
|
# Native Ollama images[] is base64-only; it does
|
||||||
|
# not fetch HTTP URLs. Skip unsupported schemes
|
||||||
|
# rather than sending a non-base64 string that the
|
||||||
|
# model silently ignores.
|
||||||
|
logger.warning(
|
||||||
|
"Skipping non-data image_url (Ollama images[] "
|
||||||
|
"requires base64): %s",
|
||||||
|
url[:80],
|
||||||
|
)
|
||||||
|
nm["content"] = "\n".join(text_parts).strip()
|
||||||
|
if images:
|
||||||
|
nm["images"] = images
|
||||||
|
|
||||||
out.append(nm)
|
out.append(nm)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compatible alias for callers/tests that imported the older name
|
||||||
|
# (it only handled tool messages originally — issue #4723 broadened scope).
|
||||||
|
_ollama_normalize_tool_messages = _ollama_normalize_messages
|
||||||
|
|
||||||
|
|
||||||
def _build_ollama_payload(
|
def _build_ollama_payload(
|
||||||
model: str,
|
model: str,
|
||||||
messages: List[Dict],
|
messages: List[Dict],
|
||||||
@@ -404,7 +463,7 @@ def _build_ollama_payload(
|
|||||||
"""
|
"""
|
||||||
payload: Dict = {
|
payload: Dict = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": _ollama_normalize_tool_messages(messages),
|
"messages": _ollama_normalize_messages(messages),
|
||||||
"stream": stream,
|
"stream": stream,
|
||||||
}
|
}
|
||||||
options: Dict = {}
|
options: Dict = {}
|
||||||
@@ -618,6 +677,8 @@ def _detect_provider(url: str) -> str:
|
|||||||
from src.copilot import is_copilot_base
|
from src.copilot import is_copilot_base
|
||||||
if is_copilot_base(url):
|
if is_copilot_base(url):
|
||||||
return "copilot"
|
return "copilot"
|
||||||
|
if _host_match(url, "mistral.ai"):
|
||||||
|
return "mistral"
|
||||||
return "openai"
|
return "openai"
|
||||||
|
|
||||||
|
|
||||||
@@ -716,10 +777,17 @@ def _provider_label(url: str) -> str:
|
|||||||
pass
|
pass
|
||||||
if _is_ollama_native_url(url): return "Ollama"
|
if _is_ollama_native_url(url): return "Ollama"
|
||||||
try:
|
try:
|
||||||
host = (urlparse(url).hostname or "").lower()
|
_parsed_local = urlparse(url)
|
||||||
|
host = (_parsed_local.hostname or "").lower()
|
||||||
|
port = _parsed_local.port
|
||||||
except Exception:
|
except Exception:
|
||||||
return "provider"
|
return "provider"
|
||||||
if host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}:
|
if host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}:
|
||||||
|
# A port alone is not authoritative: vLLM, SGLang, llama.cpp and plain
|
||||||
|
# OpenAI-compatible servers all routinely share 8000/8080, so naming the
|
||||||
|
# serving tool from the port here would mislabel real setups. The tool is
|
||||||
|
# identified by probing llama-server's native /props endpoint during
|
||||||
|
# discovery (see ModelDiscovery._fingerprint_provider); this stays neutral.
|
||||||
return "local endpoint"
|
return "local endpoint"
|
||||||
return host or "provider"
|
return host or "provider"
|
||||||
|
|
||||||
@@ -906,10 +974,17 @@ def _anthropic_rejects_temperature(model: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
|
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
|
||||||
|
|
||||||
|
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
|
||||||
|
# API accepts "high", "medium", "low", "none" — see
|
||||||
|
# https://docs.mistral.ai/capabilities/reasoning/. Override via env var
|
||||||
|
# ODYSSEUS_MISTRAL_REASONING_EFFORT (e.g. set to "medium" for cheaper chat).
|
||||||
|
_MISTRAL_REASONING_EFFORT = os.getenv("ODYSSEUS_MISTRAL_REASONING_EFFORT", "high")
|
||||||
|
|
||||||
# Models that support structured thinking — may output </think> without opening tag
|
# Models that support structured thinking — may output </think> without opening tag
|
||||||
_THINKING_MODEL_PATTERNS = (
|
_THINKING_MODEL_PATTERNS = (
|
||||||
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "minimax",
|
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "minimax",
|
||||||
"m2-reap", "gemma", "stepfun", "step-3", "step3",
|
"m2-reap", "gemma", "stepfun", "step-3", "step3",
|
||||||
|
"magistral", "mistral-small", "mistral-medium",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _supports_thinking(model: str) -> bool:
|
def _supports_thinking(model: str) -> bool:
|
||||||
@@ -919,6 +994,38 @@ def _supports_thinking(model: str) -> bool:
|
|||||||
m = model.lower()
|
m = model.lower()
|
||||||
return any(p in m for p in _THINKING_MODEL_PATTERNS)
|
return any(p in m for p in _THINKING_MODEL_PATTERNS)
|
||||||
|
|
||||||
|
def _normalize_mistral_content(content):
|
||||||
|
"""Mistral returns content as a structured array when reasoning is on:
|
||||||
|
[{"type": "thinking", "thinking": [{"type": "text", "text": "..."}], "closed": true},
|
||||||
|
{"type": "text", "text": "...final answer..."}]
|
||||||
|
Convert to (text, thinking) tuple of plain strings. Pass through strings
|
||||||
|
unchanged so non-Mistral OpenAI-compat endpoints are unaffected.
|
||||||
|
"""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content, ""
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return "", ""
|
||||||
|
text_parts = []
|
||||||
|
thinking_parts = []
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
btype = block.get("type")
|
||||||
|
if btype == "text":
|
||||||
|
t = block.get("text", "")
|
||||||
|
if t:
|
||||||
|
text_parts.append(t)
|
||||||
|
elif btype == "thinking":
|
||||||
|
inner = block.get("thinking", [])
|
||||||
|
if isinstance(inner, list):
|
||||||
|
for tb in inner:
|
||||||
|
if isinstance(tb, dict) and tb.get("text"):
|
||||||
|
thinking_parts.append(tb["text"])
|
||||||
|
elif isinstance(inner, str):
|
||||||
|
thinking_parts.append(inner)
|
||||||
|
return "".join(text_parts), "".join(thinking_parts)
|
||||||
|
|
||||||
|
|
||||||
def _convert_openai_content_to_anthropic(content):
|
def _convert_openai_content_to_anthropic(content):
|
||||||
"""Convert OpenAI multimodal content blocks to Anthropic format.
|
"""Convert OpenAI multimodal content blocks to Anthropic format.
|
||||||
|
|
||||||
@@ -1441,6 +1548,8 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
|||||||
if max_tokens and max_tokens > 0:
|
if max_tokens and max_tokens > 0:
|
||||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||||
payload[tok_key] = max_tokens
|
payload[tok_key] = max_tokens
|
||||||
|
if provider == "mistral" and _supports_thinking(model):
|
||||||
|
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||||
try:
|
try:
|
||||||
note_model_activity(target_url, model)
|
note_model_activity(target_url, model)
|
||||||
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
||||||
@@ -1456,7 +1565,16 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
|||||||
response = _parse_ollama_response(data)
|
response = _parse_ollama_response(data)
|
||||||
else:
|
else:
|
||||||
msg = data["choices"][0]["message"]
|
msg = data["choices"][0]["message"]
|
||||||
response = msg.get("content") or msg.get("reasoning_content") or ""
|
content = msg.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
# Mistral structured content — extract thinking + text
|
||||||
|
text_part, thinking_part = _normalize_mistral_content(content)
|
||||||
|
if thinking_part:
|
||||||
|
response = thinking_part + "\n\n" + (text_part or "")
|
||||||
|
else:
|
||||||
|
response = text_part or msg.get("reasoning_content") or ""
|
||||||
|
else:
|
||||||
|
response = content or msg.get("reasoning_content") or ""
|
||||||
_set_cached_response(cache_key, response)
|
_set_cached_response(cache_key, response)
|
||||||
return response
|
return response
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1638,6 +1756,8 @@ async def llm_call_async(
|
|||||||
# Suppress thinking for qwen3/gemma4 on Ollama /v1 — same as stream_llm.
|
# Suppress thinking for qwen3/gemma4 on Ollama /v1 — same as stream_llm.
|
||||||
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
|
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
|
||||||
payload["think"] = False
|
payload["think"] = False
|
||||||
|
if provider == "mistral" and _supports_thinking(model):
|
||||||
|
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||||
_apply_local_cache_affinity(payload, url, session_id)
|
_apply_local_cache_affinity(payload, url, session_id)
|
||||||
|
|
||||||
if _is_host_dead(target_url):
|
if _is_host_dead(target_url):
|
||||||
@@ -1756,6 +1876,12 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
payload[tok_key] = max_tokens
|
payload[tok_key] = max_tokens
|
||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = tools
|
payload["tools"] = tools
|
||||||
|
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
||||||
|
# activates thinking mode and returns structured reasoning_content.
|
||||||
|
# Effort level is configurable via ODYSSEUS_MISTRAL_REASONING_EFFORT
|
||||||
|
# (high / medium / low / none); default "high".
|
||||||
|
if provider == "mistral" and _supports_thinking(model):
|
||||||
|
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||||
# For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3,
|
# For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3,
|
||||||
# gemma4, etc.), suppress thinking so tool calls aren't swallowed inside
|
# gemma4, etc.), suppress thinking so tool calls aren't swallowed inside
|
||||||
# <think> blocks. Ollama /v1 accepts "think": false as a top-level param.
|
# <think> blocks. Ollama /v1 accepts "think": false as a top-level param.
|
||||||
@@ -2134,9 +2260,17 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
# Text content
|
# Text content
|
||||||
# Reasoning tokens (VLLM --reasoning-parser, e.g. Qwen3/DeepSeek-R1, Nemotron). vLLM 0.20.2 / NIM emit the field as `reasoning`; older builds use `reasoning_content`. Some OpenAI-compatible Ollama builds use `thinking`.
|
# Reasoning tokens (VLLM --reasoning-parser, e.g. Qwen3/DeepSeek-R1, Nemotron). vLLM 0.20.2 / NIM emit the field as `reasoning`; older builds use `reasoning_content`. Some OpenAI-compatible Ollama builds use `thinking`.
|
||||||
reasoning = delta.get("reasoning_content") or delta.get("reasoning") or delta.get("thinking") or ""
|
reasoning = delta.get("reasoning_content") or delta.get("reasoning") or delta.get("thinking") or ""
|
||||||
|
content = delta.get("content") or ""
|
||||||
|
# Mistral structured content: content is a list of typed blocks
|
||||||
|
# ({"type": "thinking", ...}, {"type": "text", ...}). Split into
|
||||||
|
# reasoning + text so thinking streams into the thinking panel.
|
||||||
|
if isinstance(content, list):
|
||||||
|
text_part, thinking_part = _normalize_mistral_content(content)
|
||||||
|
if thinking_part:
|
||||||
|
reasoning = (reasoning + thinking_part) if reasoning else thinking_part
|
||||||
|
content = text_part
|
||||||
if reasoning:
|
if reasoning:
|
||||||
yield _stream_delta_event(reasoning, thinking=True)
|
yield _stream_delta_event(reasoning, thinking=True)
|
||||||
content = delta.get("content") or ""
|
|
||||||
if content:
|
if content:
|
||||||
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)
|
||||||
|
|||||||
+20
-4
@@ -163,6 +163,21 @@ class ModelDiscovery:
|
|||||||
return "lmstudio"
|
return "lmstudio"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# llama.cpp's llama-server exposes a native /props endpoint (no /v1 prefix)
|
||||||
|
# describing the loaded model, slots, and chat template — distinct from
|
||||||
|
# LM Studio (/api/v1/models) and vLLM (/version, /metrics).
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"http://{host}:{port}/props", timeout=1.5)
|
||||||
|
if r.is_success:
|
||||||
|
props = r.json() or {}
|
||||||
|
if isinstance(props, dict) and (
|
||||||
|
"default_generation_settings" in props
|
||||||
|
or "total_slots" in props
|
||||||
|
or "chat_template" in props
|
||||||
|
):
|
||||||
|
return "llamacpp"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _check_port(self, host: str, port: int) -> Optional[Dict[str, Any]]:
|
def _check_port(self, host: str, port: int) -> Optional[Dict[str, Any]]:
|
||||||
@@ -194,10 +209,11 @@ class ModelDiscovery:
|
|||||||
|
|
||||||
logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}")
|
logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}")
|
||||||
|
|
||||||
# Well-known ports: 8000-8020 (vLLM, llama.cpp, SGLang, Cookbook),
|
# Well-known ports: 8000-8020 (vLLM, SGLang, Cookbook), 8080 (llama.cpp /
|
||||||
# 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL as its default port is
|
# llama-server default), 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL
|
||||||
# occupied by Ollama. The env vars can add more ports which will be merged in.
|
# as its default port is occupied by Ollama. The env vars can add more
|
||||||
ports = list(range(8000, 8021)) + [1234, 11434, 11435]
|
# ports which will be merged in.
|
||||||
|
ports = list(range(8000, 8021)) + [8080, 1234, 11434, 11435]
|
||||||
ports += [p for p in sorted(self._extra_ports) if p not in ports]
|
ports += [p for p in sorted(self._extra_ports) if p not in ports]
|
||||||
targets = [(h, p) for h in hosts for p in ports]
|
targets = [(h, p) for h in hosts for p in ports]
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ DEFAULT_SETTINGS = {
|
|||||||
# before producing output (endpoint offline / errors), the chat
|
# before producing output (endpoint offline / errors), the chat
|
||||||
# dispatch retries the next entry in order.
|
# dispatch retries the next entry in order.
|
||||||
"default_model_fallbacks": [],
|
"default_model_fallbacks": [],
|
||||||
|
# When True, non-admin users inherit global default model/endpoint/fallbacks
|
||||||
|
# when they have no personal defaults. When False, users only use their
|
||||||
|
# personal defaults (no global fallback). Default is False.
|
||||||
|
"share_defaults_with_users": False,
|
||||||
"utility_endpoint_id": "",
|
"utility_endpoint_id": "",
|
||||||
"utility_model": "",
|
"utility_model": "",
|
||||||
# Ordered fallback chain for the Utility model (summarization, naming,
|
# Ordered fallback chain for the Utility model (summarization, naming,
|
||||||
|
|||||||
+42
-2
@@ -289,6 +289,42 @@ def _checkin_calendar_events(db, owner, start, end):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_chat_endpoint(url: str) -> str:
|
||||||
|
"""Repair a resolved task endpoint to a full chat-completions URL.
|
||||||
|
|
||||||
|
Unlike the chat path — which stores ``build_chat_url(normalize_base(base))``
|
||||||
|
on the session — the task executor passes ``task.endpoint_url`` verbatim to
|
||||||
|
the model HTTP call. A bare OpenAI-compatible base such as
|
||||||
|
``http://host:11434/v1`` therefore POSTs to a 404 ("page not found") and the
|
||||||
|
model silently appears to "return an empty response".
|
||||||
|
|
||||||
|
Repair only bare OpenAI-compatible bases. Native-Ollama URLs (``/api...``)
|
||||||
|
and URLs that already point at a concrete endpoint are returned untouched, so
|
||||||
|
their own downstream normalizers keep working. Idempotent: a URL already
|
||||||
|
ending in ``/chat/completions`` is left as-is.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return url
|
||||||
|
# Imports kept function-local (endpoint_resolver pulls in heavy deps) but
|
||||||
|
# OUTSIDE the try: an import failure is a real bug that should surface, not
|
||||||
|
# be silently swallowed into the un-normalized URL this function exists to
|
||||||
|
# repair.
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from src.endpoint_resolver import normalize_base, build_chat_url
|
||||||
|
path = (urlparse(url).path or "").rstrip("/")
|
||||||
|
if path == "/api" or path.startswith("/api/"):
|
||||||
|
return url # native Ollama — handled by the native path downstream
|
||||||
|
if path.endswith(("/chat/completions", "/messages", "/responses", "/completions")):
|
||||||
|
return url # already a concrete endpoint
|
||||||
|
try:
|
||||||
|
return build_chat_url(normalize_base(url))
|
||||||
|
except Exception:
|
||||||
|
# Guard only the actual normalization. Returning the URL un-normalized
|
||||||
|
# reverts to the 404 this fixes, so make the silent revert visible.
|
||||||
|
logger.debug("task endpoint normalization failed for %r; using as-is", url, exc_info=True)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
class TaskScheduler:
|
class TaskScheduler:
|
||||||
def __init__(self, session_manager):
|
def __init__(self, session_manager):
|
||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
@@ -1357,6 +1393,7 @@ class TaskScheduler:
|
|||||||
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
raise RuntimeError("No model/endpoint configured")
|
raise RuntimeError("No model/endpoint configured")
|
||||||
|
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||||
# Record the resolved model so _execute_task_locked can persist it on
|
# Record the resolved model so _execute_task_locked can persist it on
|
||||||
# the run (tasks rarely pin a model, so this is the only record of
|
# the run (tasks rarely pin a model, so this is the only record of
|
||||||
# which model actually produced the output).
|
# which model actually produced the output).
|
||||||
@@ -1548,6 +1585,8 @@ class TaskScheduler:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||||
|
|
||||||
session_id = task.session_id
|
session_id = task.session_id
|
||||||
if not session_id:
|
if not session_id:
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
@@ -1667,7 +1706,7 @@ class TaskScheduler:
|
|||||||
msg["X-Odysseus-Ref"] = str(task.id)
|
msg["X-Odysseus-Ref"] = str(task.id)
|
||||||
msg.set_content(result or "")
|
msg.set_content(result or "")
|
||||||
_send_smtp_message(cfg, from_addr, [to_addr], msg.as_string(), timeout=30)
|
_send_smtp_message(cfg, from_addr, [to_addr], msg.as_string(), timeout=30)
|
||||||
logger.info("Task %s emailed result to %s (%sb)", task.id, to_addr, len(result or ""))
|
logger.info("Task %s emailed result (recipient_set=%s, %sb)", task.id, bool(to_addr), len(result or ""))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Task %s email delivery failed: %s", task.id, e, exc_info=True)
|
logger.error("Task %s email delivery failed: %s", task.id, e, exc_info=True)
|
||||||
raise
|
raise
|
||||||
@@ -1821,6 +1860,7 @@ class TaskScheduler:
|
|||||||
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
raise RuntimeError("No model/endpoint configured for research")
|
raise RuntimeError("No model/endpoint configured for research")
|
||||||
|
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||||
# Record the resolved model for the run record (see _execute_task_locked).
|
# Record the resolved model for the run record (see _execute_task_locked).
|
||||||
self._last_run_model = model
|
self._last_run_model = model
|
||||||
|
|
||||||
@@ -2029,7 +2069,7 @@ class TaskScheduler:
|
|||||||
# silent SMTP failure is easier to spot in the logs.
|
# silent SMTP failure is easier to spot in the logs.
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Task {task.id} delivered via MCP tool {tool_name} "
|
f"Task {task.id} delivered via MCP tool {tool_name} "
|
||||||
f"(to={recipient or '<unset>'}, body={body_len}b, reply={stdout[:200]!r})"
|
f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
||||||
|
|||||||
+6
-18
@@ -563,9 +563,7 @@ async def _execute_tool_block_impl(
|
|||||||
"""
|
"""
|
||||||
from src.tool_implementations import (
|
from src.tool_implementations import (
|
||||||
do_search_chats, do_manage_tasks,
|
do_search_chats, do_manage_tasks,
|
||||||
do_manage_skills, do_api_call, do_manage_endpoints,
|
do_manage_skills, do_api_call, do_manage_notes,
|
||||||
do_manage_mcp, do_manage_webhooks, do_manage_tokens,
|
|
||||||
do_manage_settings, do_manage_notes,
|
|
||||||
do_manage_calendar,
|
do_manage_calendar,
|
||||||
do_download_model, do_serve_model, do_list_served_models, do_stop_served_model,
|
do_download_model, do_serve_model, do_list_served_models, do_stop_served_model,
|
||||||
do_tail_serve_output,
|
do_tail_serve_output,
|
||||||
@@ -808,21 +806,11 @@ async def _execute_tool_block_impl(
|
|||||||
first_line = content.split("\n")[0].strip()[:60]
|
first_line = content.split("\n")[0].strip()[:60]
|
||||||
desc = f"api_call: {first_line}"
|
desc = f"api_call: {first_line}"
|
||||||
result = await do_api_call(content)
|
result = await do_api_call(content)
|
||||||
elif tool == "manage_endpoints":
|
elif tool in ("manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "manage_settings"):
|
||||||
desc = "manage_endpoints"
|
# Registry-dispatched (agent_tools.admin_tools); owner threaded for ownership/admin checks.
|
||||||
result = await do_manage_endpoints(content, owner=owner)
|
desc = tool
|
||||||
elif tool == "manage_mcp":
|
result = await _direct_fallback(tool, content, owner=owner) \
|
||||||
desc = "manage_mcp"
|
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||||
result = await do_manage_mcp(content, owner=owner)
|
|
||||||
elif tool == "manage_webhooks":
|
|
||||||
desc = "manage_webhooks"
|
|
||||||
result = await do_manage_webhooks(content, owner=owner)
|
|
||||||
elif tool == "manage_tokens":
|
|
||||||
desc = "manage_tokens"
|
|
||||||
result = await do_manage_tokens(content, owner=owner)
|
|
||||||
elif tool == "manage_settings":
|
|
||||||
desc = "manage_settings"
|
|
||||||
result = await do_manage_settings(content, owner=owner)
|
|
||||||
elif tool == "manage_notes":
|
elif tool == "manage_notes":
|
||||||
desc = "manage_notes"
|
desc = "manage_notes"
|
||||||
result = await do_manage_notes(content, owner=owner)
|
result = await do_manage_notes(content, owner=owner)
|
||||||
|
|||||||
+2
-785
@@ -14,7 +14,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from src.constants import MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE
|
from src.constants import MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE
|
||||||
from src.tool_utils import get_mcp_manager
|
from src.tool_utils import get_mcp_manager, _parse_tool_args
|
||||||
from core.constants import internal_api_base
|
from core.constants import internal_api_base
|
||||||
from routes._validators import validate_remote_host, validate_ssh_port
|
from routes._validators import validate_remote_host, validate_ssh_port
|
||||||
|
|
||||||
@@ -68,38 +68,6 @@ def clear_active_email() -> None:
|
|||||||
# Argument parsing
|
# Argument parsing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _parse_tool_args(content):
|
|
||||||
"""Parse a tool-call argument blob.
|
|
||||||
|
|
||||||
Accepts either a JSON string or an already-decoded dict. Unwraps the
|
|
||||||
common `{"body": {...}}` envelope that smaller models emit when they
|
|
||||||
read tool descriptions like "Body is JSON: {...}" literally — they
|
|
||||||
pass `body` as a field name rather than treating it as a noun.
|
|
||||||
|
|
||||||
Returns a dict on success, raises ValueError on bad JSON.
|
|
||||||
"""
|
|
||||||
if isinstance(content, str):
|
|
||||||
try:
|
|
||||||
args = json.loads(content) if content.strip() else {}
|
|
||||||
except (json.JSONDecodeError, TypeError) as e:
|
|
||||||
raise ValueError(str(e))
|
|
||||||
elif isinstance(content, dict):
|
|
||||||
args = content
|
|
||||||
else:
|
|
||||||
args = {}
|
|
||||||
# Unwrap {"body": {...}} envelope — but only if `body` is the sole key
|
|
||||||
# and points at a dict. We don't want to clobber a legitimate `body`
|
|
||||||
# field on tools where it's a real arg (e.g. send_email body text).
|
|
||||||
if (
|
|
||||||
isinstance(args, dict)
|
|
||||||
and len(args) == 1
|
|
||||||
and "body" in args
|
|
||||||
and isinstance(args["body"], dict)
|
|
||||||
and "action" in args["body"] # extra safety: only unwrap if the inner dict looks like a tool call
|
|
||||||
):
|
|
||||||
args = args["body"]
|
|
||||||
return args
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Search chats
|
# Search chats
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -588,757 +556,6 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Endpoint management tool
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""Manage model endpoints: list, add, delete, enable, disable."""
|
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
|
||||||
try:
|
|
||||||
args = _parse_tool_args(content)
|
|
||||||
except ValueError:
|
|
||||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
|
||||||
|
|
||||||
action = args.get("action", "list")
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
if action == "list":
|
|
||||||
eps = db.query(ModelEndpoint).all()
|
|
||||||
items = [{"id": e.id, "name": e.name, "base_url": e.base_url,
|
|
||||||
"is_enabled": e.is_enabled} for e in eps]
|
|
||||||
return {"response": f"{len(items)} endpoints", "endpoints": items, "exit_code": 0}
|
|
||||||
|
|
||||||
elif action == "add":
|
|
||||||
import uuid as _uuid
|
|
||||||
name = args.get("name", "")
|
|
||||||
base_url = args.get("base_url", "")
|
|
||||||
api_key = args.get("api_key", "")
|
|
||||||
if not base_url:
|
|
||||||
return {"error": "base_url is required", "exit_code": 1}
|
|
||||||
eid = str(_uuid.uuid4())[:8]
|
|
||||||
from datetime import datetime
|
|
||||||
ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url,
|
|
||||||
api_key=api_key, is_enabled=True,
|
|
||||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
|
||||||
db.add(ep)
|
|
||||||
db.commit()
|
|
||||||
return {"response": f"Added endpoint '{name or base_url}' (id: {eid})", "exit_code": 0}
|
|
||||||
|
|
||||||
elif action == "delete":
|
|
||||||
eid = args.get("endpoint_id", "")
|
|
||||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
|
|
||||||
if not ep:
|
|
||||||
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
|
|
||||||
name = ep.name
|
|
||||||
db.delete(ep)
|
|
||||||
db.commit()
|
|
||||||
return {"response": f"Deleted endpoint '{name}'", "exit_code": 0}
|
|
||||||
|
|
||||||
elif action in ("enable", "disable"):
|
|
||||||
eid = args.get("endpoint_id", "")
|
|
||||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
|
|
||||||
if not ep:
|
|
||||||
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
|
|
||||||
ep.is_enabled = (action == "enable")
|
|
||||||
db.commit()
|
|
||||||
return {"response": f"Endpoint '{ep.name}' {action}d", "exit_code": 0}
|
|
||||||
|
|
||||||
else:
|
|
||||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"manage_endpoints error: {e}")
|
|
||||||
return {"error": str(e), "exit_code": 1}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# MCP server management tool
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Parallel to routes/cookbook_helpers._validate_serve_cmd but deliberately the
|
|
||||||
# opposite policy: that gate guards an admin-only serve command and allows
|
|
||||||
# interpreters (python3/etc) because model-serving needs them, whereas this is
|
|
||||||
# the model/prompt-injection-reachable manage_mcp path, so interpreters and
|
|
||||||
# runners are denied here.
|
|
||||||
#
|
|
||||||
# Commands that can execute arbitrary code regardless of their arguments. These
|
|
||||||
# are NEVER accepted on the manage_mcp agent path, even if an operator lists one
|
|
||||||
# in ODYSSEUS_MCP_ALLOWED_COMMANDS -- a stdio server that genuinely needs an
|
|
||||||
# interpreter or package runner must be registered via the trusted admin route.
|
|
||||||
_MCP_DENIED_COMMANDS = frozenset({
|
|
||||||
"sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "ash", "busybox",
|
|
||||||
"cmd", "command.com", "powershell", "pwsh",
|
|
||||||
"python", "pypy", "node", "nodejs", "deno", "bun", "ruby", "jruby",
|
|
||||||
"perl", "raku", "php", "lua", "luajit", "tclsh", "wish", "expect", "rscript",
|
|
||||||
"groovy", "scala", "elixir", "erl", "iex", "java", "javac", "jshell", "jbang",
|
|
||||||
"kotlin", "kotlinc", "dotnet", "mono", "swift", "osascript", "tsx", "ts-node",
|
|
||||||
"npx", "bunx", "uvx", "pipx", "npm", "pnpm", "yarn", "pip", "uv",
|
|
||||||
"gem", "cargo", "go", "bundle", "poetry", "conda", "mamba", "brew",
|
|
||||||
"apt", "apt-get", "yum", "dnf", "pacman", "apk",
|
|
||||||
"env", "xargs", "nohup", "setsid", "nice", "ionice", "time", "timeout",
|
|
||||||
"watch", "stdbuf", "unbuffer", "script", "ssh", "scp", "sshpass", "sudo",
|
|
||||||
"doas", "su", "make", "cmake", "docker", "podman", "kubectl", "find",
|
|
||||||
"awk", "gawk", "sed", "vi", "vim", "nvim", "emacs", "ed", "tee", "eval",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Argv flags that make even an allowlisted binary execute inline code. Matched
|
|
||||||
# by prefix so glued forms (-cimport os, --eval=...) are caught, not just the
|
|
||||||
# exact-token form.
|
|
||||||
_MCP_CODE_EXEC_SHORT_FLAGS = ("-c", "-e", "-m")
|
|
||||||
_MCP_CODE_EXEC_LONG_FLAGS = ("--eval", "--exec", "--print", "--module", "--command", "--require")
|
|
||||||
|
|
||||||
_MCP_URL_SCHEMES = ("http://", "https://", "ftp://", "ftps://", "file://", "data:", "jar:", "blob:")
|
|
||||||
|
|
||||||
# Shell metacharacters refused in command/args. Args are passed as an argv list
|
|
||||||
# (no shell), but refusing these keeps the surface narrow and obvious.
|
|
||||||
_MCP_SHELL_METACHARS = set(";|&$`><\n\r")
|
|
||||||
|
|
||||||
# Env vars that let a child process load attacker-supplied code before main().
|
|
||||||
_MCP_DANGEROUS_ENV = frozenset({
|
|
||||||
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "DYLD_INSERT_LIBRARIES",
|
|
||||||
"DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "PYTHONPATH", "PYTHONSTARTUP",
|
|
||||||
"PYTHONHOME", "PYTHONEXECUTABLE", "NODE_OPTIONS", "NODE_PATH", "BASH_ENV",
|
|
||||||
"ENV", "SHELLOPTS", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "GEM_PATH",
|
|
||||||
"R_PROFILE", "R_HOME", "PATH", "IFS", "PROMPT_COMMAND",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _mcp_allowed_commands() -> set:
|
|
||||||
"""Operator-configured allowlist of safe MCP launcher basenames for the agent
|
|
||||||
path. Empty by default; set ODYSSEUS_MCP_ALLOWED_COMMANDS (comma-separated)
|
|
||||||
to opt specific trusted binaries in. Denied commands are rejected even if
|
|
||||||
listed here."""
|
|
||||||
raw = os.environ.get("ODYSSEUS_MCP_ALLOWED_COMMANDS", "")
|
|
||||||
return {c.strip().lower() for c in raw.split(",") if c.strip()}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_mcp_command(command, args, env) -> Optional[str]:
|
|
||||||
"""Validate a model-supplied stdio MCP registration. Returns an error string
|
|
||||||
if it must be rejected, else None.
|
|
||||||
|
|
||||||
Closes the RCE where manage_mcp 'add' passed prompt-injection-controlled
|
|
||||||
command/args/env straight to a subprocess spawn (issue #438): a payload
|
|
||||||
smuggled into a skill description, memory entry, fetched page, or email body
|
|
||||||
could register a stdio server running arbitrary code as the app UID.
|
|
||||||
"""
|
|
||||||
if not isinstance(command, str) or not command.strip():
|
|
||||||
return "command must be a non-empty string"
|
|
||||||
command = command.strip()
|
|
||||||
if "/" in command or "\\" in command:
|
|
||||||
return "command must be a bare executable name, not a path"
|
|
||||||
if any(ch in _MCP_SHELL_METACHARS for ch in command):
|
|
||||||
return "command contains shell metacharacters"
|
|
||||||
base = command.lower()
|
|
||||||
if base.endswith(".exe") or base.endswith(".cmd") or base.endswith(".bat"):
|
|
||||||
base = base.rsplit(".", 1)[0]
|
|
||||||
# Canonicalize a trailing version suffix so versioned aliases collapse to the
|
|
||||||
# family name (python3.11 -> python, node18 -> node, pip3 -> pip); both the
|
|
||||||
# raw basename and the canonical form are denied, so an operator cannot
|
|
||||||
# accidentally allowlist a runtime alias back into the path.
|
|
||||||
canon = re.sub(r"[-_.]?\d+(?:\.\d+)*$", "", base)
|
|
||||||
if base in _MCP_DENIED_COMMANDS or canon in _MCP_DENIED_COMMANDS:
|
|
||||||
return (
|
|
||||||
f"command '{command}' is not allowed on the agent MCP path: "
|
|
||||||
"interpreters, runtimes, package runners, and shells can execute "
|
|
||||||
"arbitrary code. Register such a server via the admin route instead."
|
|
||||||
)
|
|
||||||
if base not in _mcp_allowed_commands():
|
|
||||||
return (
|
|
||||||
f"command '{command}' is not in the MCP allowlist. Add it to "
|
|
||||||
"ODYSSEUS_MCP_ALLOWED_COMMANDS if you trust it, or register the "
|
|
||||||
"server via the admin route."
|
|
||||||
)
|
|
||||||
|
|
||||||
if args is not None:
|
|
||||||
if isinstance(args, str):
|
|
||||||
try:
|
|
||||||
args = json.loads(args)
|
|
||||||
except Exception:
|
|
||||||
return "args must be a JSON list"
|
|
||||||
if not isinstance(args, list):
|
|
||||||
return "args must be a list"
|
|
||||||
for a in args:
|
|
||||||
if not isinstance(a, str):
|
|
||||||
return "args must all be strings"
|
|
||||||
s = a.strip()
|
|
||||||
low = s.lower()
|
|
||||||
if any(s == f or s.startswith(f) for f in _MCP_CODE_EXEC_SHORT_FLAGS):
|
|
||||||
return f"arg '{a}' is a code-execution flag and is not allowed"
|
|
||||||
if any(low == f or low.startswith(f + "=") for f in _MCP_CODE_EXEC_LONG_FLAGS):
|
|
||||||
return f"arg '{a}' is a code-execution flag and is not allowed"
|
|
||||||
if any(low.startswith(u) for u in _MCP_URL_SCHEMES):
|
|
||||||
return f"arg '{a}' is a remote URL and is not allowed"
|
|
||||||
if any(ch in _MCP_SHELL_METACHARS for ch in a):
|
|
||||||
return f"arg '{a}' contains shell metacharacters"
|
|
||||||
|
|
||||||
if env:
|
|
||||||
if isinstance(env, str):
|
|
||||||
try:
|
|
||||||
env = json.loads(env)
|
|
||||||
except Exception:
|
|
||||||
return "env must be a JSON object"
|
|
||||||
if not isinstance(env, dict):
|
|
||||||
return "env must be an object"
|
|
||||||
for k in env:
|
|
||||||
if str(k).strip().upper() in _MCP_DANGEROUS_ENV:
|
|
||||||
return f"env var '{k}' can inject code into the child process and is not allowed"
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def do_manage_mcp(content: str, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""Manage MCP servers: list, add, delete, enable, disable, reconnect."""
|
|
||||||
try:
|
|
||||||
args = _parse_tool_args(content)
|
|
||||||
except ValueError:
|
|
||||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
|
||||||
|
|
||||||
action = args.get("action", "list")
|
|
||||||
|
|
||||||
if action == "list":
|
|
||||||
mcp = get_mcp_manager()
|
|
||||||
if not mcp:
|
|
||||||
return {"response": "No MCP manager available", "servers": [], "exit_code": 0}
|
|
||||||
from core.database import SessionLocal, McpServer
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
servers = db.query(McpServer).all()
|
|
||||||
items = []
|
|
||||||
for s in servers:
|
|
||||||
st = mcp.get_server_status(s.id)
|
|
||||||
status = st.get("status", "disconnected")
|
|
||||||
tool_count = st.get("tool_count", 0)
|
|
||||||
items.append({"id": s.id, "name": s.name, "transport": s.transport,
|
|
||||||
"is_enabled": s.is_enabled, "status": status,
|
|
||||||
"tool_count": tool_count})
|
|
||||||
return {"response": f"{len(items)} MCP servers", "servers": items, "exit_code": 0}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
elif action == "add":
|
|
||||||
from core.database import SessionLocal, McpServer
|
|
||||||
import uuid as _uuid
|
|
||||||
from datetime import datetime
|
|
||||||
name = args.get("name", "")
|
|
||||||
command = args.get("command", "")
|
|
||||||
cmd_args = args.get("args", [])
|
|
||||||
env = args.get("env", {})
|
|
||||||
if not name or not command:
|
|
||||||
return {"error": "name and command are required", "exit_code": 1}
|
|
||||||
# Validate BEFORE any DB write or spawn: a rejected registration must
|
|
||||||
# leave no enabled row (which would otherwise auto-reconnect on restart)
|
|
||||||
# and must not attempt a connection.
|
|
||||||
_mcp_err = _validate_mcp_command(command, cmd_args, env)
|
|
||||||
if _mcp_err:
|
|
||||||
return {"error": f"manage_mcp: refused unsafe server registration: {_mcp_err}", "exit_code": 1}
|
|
||||||
sid = str(_uuid.uuid4())[:8]
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = McpServer(id=sid, name=name, transport="stdio", command=command,
|
|
||||||
args=json.dumps(cmd_args) if isinstance(cmd_args, list) else cmd_args,
|
|
||||||
env=json.dumps(env) if isinstance(env, dict) else env,
|
|
||||||
is_enabled=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
|
||||||
db.add(srv)
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
# Try to connect
|
|
||||||
mcp = get_mcp_manager()
|
|
||||||
tool_count = 0
|
|
||||||
if mcp:
|
|
||||||
try:
|
|
||||||
await mcp.connect_server(
|
|
||||||
sid, name, "stdio", command=command,
|
|
||||||
args=cmd_args if isinstance(cmd_args, list) else json.loads(cmd_args),
|
|
||||||
env=env if isinstance(env, dict) else json.loads(env),
|
|
||||||
)
|
|
||||||
st = mcp.get_server_status(sid)
|
|
||||||
tool_count = st.get("tool_count", 0)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"MCP connect failed for {name}: {e}")
|
|
||||||
return {"response": f"Added MCP server '{name}' ({tool_count} tools)", "exit_code": 0}
|
|
||||||
|
|
||||||
elif action == "delete":
|
|
||||||
sid = args.get("server_id", "")
|
|
||||||
from core.database import SessionLocal, McpServer
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = db.query(McpServer).filter(McpServer.id == sid).first()
|
|
||||||
if not srv:
|
|
||||||
return {"error": f"Server {sid} not found", "exit_code": 1}
|
|
||||||
name = srv.name
|
|
||||||
mcp = get_mcp_manager()
|
|
||||||
if mcp:
|
|
||||||
try:
|
|
||||||
await mcp.disconnect_server(sid)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
db.delete(srv)
|
|
||||||
db.commit()
|
|
||||||
return {"response": f"Deleted MCP server '{name}'", "exit_code": 0}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
elif action == "reconnect":
|
|
||||||
sid = args.get("server_id", "")
|
|
||||||
mcp = get_mcp_manager()
|
|
||||||
if not mcp:
|
|
||||||
return {"error": "MCP manager not available", "exit_code": 1}
|
|
||||||
try:
|
|
||||||
await mcp.disconnect_server(sid)
|
|
||||||
from core.database import SessionLocal, McpServer
|
|
||||||
db2 = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = db2.query(McpServer).filter(McpServer.id == sid).first()
|
|
||||||
if srv:
|
|
||||||
_args = json.loads(srv.args) if srv.args else []
|
|
||||||
_env = json.loads(srv.env) if srv.env else {}
|
|
||||||
await mcp.connect_server(
|
|
||||||
server_id=sid,
|
|
||||||
name=srv.name,
|
|
||||||
transport=srv.transport,
|
|
||||||
command=srv.command,
|
|
||||||
args=_args,
|
|
||||||
env=_env,
|
|
||||||
url=srv.url,
|
|
||||||
)
|
|
||||||
st = mcp.get_server_status(sid)
|
|
||||||
return {"response": f"Reconnected '{srv.name}' ({st.get('tool_count', 0)} tools)", "exit_code": 0}
|
|
||||||
return {"error": f"Server {sid} not found", "exit_code": 1}
|
|
||||||
finally:
|
|
||||||
db2.close()
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": str(e), "exit_code": 1}
|
|
||||||
|
|
||||||
elif action in ("enable", "disable"):
|
|
||||||
sid = args.get("server_id", "")
|
|
||||||
from core.database import SessionLocal, McpServer
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = db.query(McpServer).filter(McpServer.id == sid).first()
|
|
||||||
if not srv:
|
|
||||||
return {"error": f"Server {sid} not found", "exit_code": 1}
|
|
||||||
srv.is_enabled = (action == "enable")
|
|
||||||
db.commit()
|
|
||||||
return {"response": f"MCP server '{srv.name}' {action}d", "exit_code": 0}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
elif action == "list_tools":
|
|
||||||
mcp = get_mcp_manager()
|
|
||||||
if not mcp:
|
|
||||||
return {"response": "No MCP manager", "tools": [], "exit_code": 0}
|
|
||||||
tools = mcp.get_all_tools()
|
|
||||||
items = [{"name": t["name"], "server": t["server_name"],
|
|
||||||
"description": t.get("description", "")[:100]} for t in tools]
|
|
||||||
return {"response": f"{len(items)} MCP tools available", "tools": items, "exit_code": 0}
|
|
||||||
|
|
||||||
else:
|
|
||||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Webhook management tool
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def do_manage_webhooks(content: str, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""Manage webhooks: list, add, delete, enable, disable, test."""
|
|
||||||
from core.database import SessionLocal
|
|
||||||
try:
|
|
||||||
args = _parse_tool_args(content)
|
|
||||||
except ValueError:
|
|
||||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
|
||||||
|
|
||||||
action = args.get("action", "list")
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
from core.database import Webhook
|
|
||||||
if action == "list":
|
|
||||||
hooks = db.query(Webhook).all()
|
|
||||||
items = [{"id": h.id, "name": h.name, "url": h.url,
|
|
||||||
"events": h.events, "is_active": h.is_active} for h in hooks]
|
|
||||||
return {"response": f"{len(items)} webhooks", "webhooks": items, "exit_code": 0}
|
|
||||||
|
|
||||||
elif action == "add":
|
|
||||||
import uuid as _uuid
|
|
||||||
from datetime import datetime
|
|
||||||
from src.webhook_manager import validate_events, validate_webhook_url
|
|
||||||
name = args.get("name", "")
|
|
||||||
url = args.get("url", "")
|
|
||||||
events = args.get("events", "chat.completed")
|
|
||||||
if not url:
|
|
||||||
return {"error": "url is required", "exit_code": 1}
|
|
||||||
try:
|
|
||||||
url = validate_webhook_url(url)
|
|
||||||
events = validate_events(events)
|
|
||||||
except ValueError as e:
|
|
||||||
return {"error": str(e), "exit_code": 1}
|
|
||||||
wid = str(_uuid.uuid4())[:8]
|
|
||||||
hook = Webhook(id=wid, name=name or url, url=url,
|
|
||||||
events=events, is_active=True,
|
|
||||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
|
||||||
db.add(hook)
|
|
||||||
db.commit()
|
|
||||||
return {"response": f"Added webhook '{name or url}'", "exit_code": 0}
|
|
||||||
|
|
||||||
elif action == "delete":
|
|
||||||
wid = args.get("webhook_id", "")
|
|
||||||
hook = db.query(Webhook).filter(Webhook.id == wid).first()
|
|
||||||
if not hook:
|
|
||||||
return {"error": f"Webhook {wid} not found", "exit_code": 1}
|
|
||||||
name = hook.name
|
|
||||||
db.delete(hook)
|
|
||||||
db.commit()
|
|
||||||
return {"response": f"Deleted webhook '{name}'", "exit_code": 0}
|
|
||||||
|
|
||||||
elif action in ("enable", "disable"):
|
|
||||||
wid = args.get("webhook_id", "")
|
|
||||||
hook = db.query(Webhook).filter(Webhook.id == wid).first()
|
|
||||||
if not hook:
|
|
||||||
return {"error": f"Webhook {wid} not found", "exit_code": 1}
|
|
||||||
hook.is_active = (action == "enable")
|
|
||||||
db.commit()
|
|
||||||
return {"response": f"Webhook '{hook.name}' {action}d", "exit_code": 0}
|
|
||||||
|
|
||||||
else:
|
|
||||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"manage_webhooks error: {e}")
|
|
||||||
return {"error": str(e), "exit_code": 1}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# API token management tool
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def do_manage_tokens(content: str, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""Manage API tokens: list, create, delete."""
|
|
||||||
from core.database import SessionLocal, ApiToken
|
|
||||||
try:
|
|
||||||
args = _parse_tool_args(content)
|
|
||||||
except ValueError:
|
|
||||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
|
||||||
|
|
||||||
action = args.get("action", "list")
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
if action == "list":
|
|
||||||
tokens = db.query(ApiToken).all()
|
|
||||||
items = [{"id": t.id, "name": t.name, "token_prefix": t.token_prefix + "...",
|
|
||||||
"is_active": t.is_active} for t in tokens]
|
|
||||||
return {"response": f"{len(items)} API tokens", "tokens": items, "exit_code": 0}
|
|
||||||
|
|
||||||
elif action == "create":
|
|
||||||
import uuid as _uuid, secrets, bcrypt
|
|
||||||
from datetime import datetime
|
|
||||||
name = args.get("name", "API Token")
|
|
||||||
raw_token = secrets.token_urlsafe(32)
|
|
||||||
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
|
||||||
tid = str(_uuid.uuid4())[:8]
|
|
||||||
t = ApiToken(id=tid, name=name, token_hash=token_hash,
|
|
||||||
token_prefix=raw_token[:8], is_active=True,
|
|
||||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
|
||||||
db.add(t)
|
|
||||||
db.commit()
|
|
||||||
return {"response": f"Created token '{name}'", "token": raw_token, "exit_code": 0}
|
|
||||||
|
|
||||||
elif action == "delete":
|
|
||||||
tid = args.get("token_id", "")
|
|
||||||
t = db.query(ApiToken).filter(ApiToken.id == tid).first()
|
|
||||||
if not t:
|
|
||||||
return {"error": f"Token {tid} not found", "exit_code": 1}
|
|
||||||
name = t.name
|
|
||||||
db.delete(t)
|
|
||||||
db.commit()
|
|
||||||
return {"response": f"Deleted token '{name}'", "exit_code": 0}
|
|
||||||
|
|
||||||
else:
|
|
||||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"manage_tokens error: {e}")
|
|
||||||
return {"error": str(e), "exit_code": 1}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Settings/preferences management tool
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""Manage user settings and preferences."""
|
|
||||||
try:
|
|
||||||
args = _parse_tool_args(content)
|
|
||||||
except ValueError:
|
|
||||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
|
||||||
|
|
||||||
action = args.get("action", "list")
|
|
||||||
|
|
||||||
from core.database import SessionLocal
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# set/get/list/delete operate on the REAL app settings (the same store
|
|
||||||
# the Settings panel writes), so changing a model / voice / search
|
|
||||||
# engine / reminder channel from chat actually takes effect.
|
|
||||||
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
|
|
||||||
|
|
||||||
# Secrets/credentials the agent must NOT write — kept read-only (masked)
|
|
||||||
# so API keys never flow through chat. User sets these in the panel.
|
|
||||||
_SECRET_KEYS = {
|
|
||||||
"brave_api_key", "google_pse_key", "google_pse_cx",
|
|
||||||
"tavily_api_key", "serper_api_key", "app_public_url",
|
|
||||||
}
|
|
||||||
def _is_secret(k):
|
|
||||||
# `token` must be a suffix, not a substring: otherwise the int
|
|
||||||
# setting `agent_input_token_budget` (which even has a "token budget"
|
|
||||||
# alias to set it from chat) is wrongly classified as a credential.
|
|
||||||
return (
|
|
||||||
k in _SECRET_KEYS
|
|
||||||
or k.endswith("token")
|
|
||||||
or any(t in k for t in ("api_key", "_key", "secret", "password"))
|
|
||||||
)
|
|
||||||
|
|
||||||
# Friendly aliases → real keys, so natural phrasing resolves.
|
|
||||||
_ALIASES_SET = {
|
|
||||||
"voice": "tts_voice", "tts voice": "tts_voice", "tts": "tts_enabled",
|
|
||||||
"text to speech": "tts_enabled", "tts provider": "tts_provider",
|
|
||||||
"speech speed": "tts_speed", "voice speed": "tts_speed",
|
|
||||||
"stt": "stt_enabled", "speech to text": "stt_enabled", "transcription": "stt_enabled",
|
|
||||||
"search engine": "search_provider", "search provider": "search_provider",
|
|
||||||
"search results": "search_result_count", "result count": "search_result_count",
|
|
||||||
"default model": "default_model", "chat model": "default_model",
|
|
||||||
"default endpoint": "default_endpoint_id",
|
|
||||||
"task model": "task_model", "background model": "task_model",
|
|
||||||
"teacher model": "teacher_model", "teacher": "teacher_enabled",
|
|
||||||
"utility model": "utility_model", "research model": "research_model",
|
|
||||||
"research max tokens": "research_max_tokens",
|
|
||||||
"vision model": "vision_model", "vision": "vision_enabled",
|
|
||||||
"image model": "image_model", "image quality": "image_quality",
|
|
||||||
"image gen": "image_gen_enabled", "image generation": "image_gen_enabled",
|
|
||||||
"reminder channel": "reminder_channel", "reminders": "reminder_channel",
|
|
||||||
"ntfy topic": "reminder_ntfy_topic",
|
|
||||||
"webhook integration": "reminder_webhook_integration_id",
|
|
||||||
"webhook template": "reminder_webhook_payload_template", "webhook payload": "reminder_webhook_payload_template",
|
|
||||||
"agent tool calls": "agent_max_tool_calls", "max tool calls": "agent_max_tool_calls",
|
|
||||||
"agent timeout": "agent_stream_timeout_seconds", "stream timeout": "agent_stream_timeout_seconds",
|
|
||||||
"token budget": "agent_input_token_budget", "input budget": "agent_input_token_budget",
|
|
||||||
"hard max": "agent_input_token_hard_max",
|
|
||||||
"token budget cap": "agent_input_token_hard_max",
|
|
||||||
"input budget cap": "agent_input_token_hard_max",
|
|
||||||
}
|
|
||||||
def _resolve(k):
|
|
||||||
k2 = (k or "").strip().lower()
|
|
||||||
if k2 in DEFAULT_SETTINGS:
|
|
||||||
return k2
|
|
||||||
return _ALIASES_SET.get(k2, (k or "").strip())
|
|
||||||
|
|
||||||
_ENUMS = {
|
|
||||||
"image_quality": ["low", "medium", "high"],
|
|
||||||
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
|
|
||||||
}
|
|
||||||
def _coerce(value, default):
|
|
||||||
if isinstance(default, bool):
|
|
||||||
return value if isinstance(value, bool) else str(value).strip().lower() in ("true", "on", "yes", "1", "enable", "enabled")
|
|
||||||
if isinstance(default, int):
|
|
||||||
return int(value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _model_slug(value: str) -> str:
|
|
||||||
import re as _re
|
|
||||||
return _re.sub(r"[^a-z0-9]+", "", (value or "").lower())
|
|
||||||
|
|
||||||
def _endpoint_model_from_cache(model_query: str):
|
|
||||||
"""Resolve friendly model text to an enabled endpoint + real model id.
|
|
||||||
|
|
||||||
The Settings UI stores both `<prefix>_endpoint_id` and
|
|
||||||
`<prefix>_model`; writing only the model leaves the runtime on the
|
|
||||||
old endpoint. Prefer cached model lists so this stays fast/offline.
|
|
||||||
"""
|
|
||||||
import json as _json
|
|
||||||
import re as _re
|
|
||||||
from core.database import ModelEndpoint
|
|
||||||
|
|
||||||
wanted = (model_query or "").strip()
|
|
||||||
wanted_slug = _model_slug(wanted)
|
|
||||||
wanted_tokens = [_model_slug(t) for t in _re.findall(r"[A-Za-z0-9]+", wanted)]
|
|
||||||
wanted_tokens = [t for t in wanted_tokens if t]
|
|
||||||
if not wanted_slug:
|
|
||||||
return None
|
|
||||||
best = None
|
|
||||||
for ep in db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all():
|
|
||||||
raw_models = []
|
|
||||||
try:
|
|
||||||
raw_models = _json.loads(ep.cached_models or "[]") or []
|
|
||||||
except Exception:
|
|
||||||
raw_models = []
|
|
||||||
# If cache is empty, still allow matching against endpoint name
|
|
||||||
# for callers using model@endpoint elsewhere later.
|
|
||||||
for mid in raw_models:
|
|
||||||
mid = str(mid)
|
|
||||||
mid_slug = _model_slug(mid)
|
|
||||||
if not mid_slug:
|
|
||||||
continue
|
|
||||||
exact = mid.lower() == wanted.lower()
|
|
||||||
compact_match = wanted_slug in mid_slug or mid_slug in wanted_slug
|
|
||||||
token_match = bool(wanted_tokens) and all(tok in mid_slug for tok in wanted_tokens)
|
|
||||||
if exact or compact_match or token_match:
|
|
||||||
score = 3 if exact else (2 if compact_match else 1)
|
|
||||||
if not best or score > best[0]:
|
|
||||||
best = (score, ep.id, mid)
|
|
||||||
if best:
|
|
||||||
return {"endpoint_id": best[1], "model": best[2]}
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _mask(k, v):
|
|
||||||
return "••••• (set in panel)" if _is_secret(k) and v else v
|
|
||||||
|
|
||||||
if action == "list":
|
|
||||||
s = load_settings()
|
|
||||||
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
|
|
||||||
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
|
|
||||||
|
|
||||||
elif action == "get":
|
|
||||||
key = _resolve(args.get("key", ""))
|
|
||||||
if not key:
|
|
||||||
return {"error": "key is required", "exit_code": 1}
|
|
||||||
if key not in DEFAULT_SETTINGS:
|
|
||||||
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
|
|
||||||
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
|
|
||||||
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
|
|
||||||
|
|
||||||
elif action == "set":
|
|
||||||
raw = args.get("key", "")
|
|
||||||
value = args.get("value")
|
|
||||||
if not raw:
|
|
||||||
return {"error": "key is required", "exit_code": 1}
|
|
||||||
key = _resolve(raw)
|
|
||||||
if key not in DEFAULT_SETTINGS:
|
|
||||||
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
|
|
||||||
if _is_secret(key):
|
|
||||||
return {"response": f"'{key}' is a credential/secret — for security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
|
|
||||||
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
|
|
||||||
# have no safe scalar coercion — _coerce would pass a bare string
|
|
||||||
# straight through and clobber the structure. Refuse them here; they're
|
|
||||||
# edited in their dedicated panels. (reset/delete still restore the
|
|
||||||
# default structure, which is safe.)
|
|
||||||
if isinstance(DEFAULT_SETTINGS[key], (dict, list)):
|
|
||||||
return {"response": f"'{key}' is a structured setting — edit it in its panel, not from chat. (You can reset it to default here.)", "exit_code": 0}
|
|
||||||
try:
|
|
||||||
value = _coerce(value, DEFAULT_SETTINGS[key])
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return {"error": f"'{value}' isn't a valid value for {key} (expected {type(DEFAULT_SETTINGS[key]).__name__}).", "exit_code": 1}
|
|
||||||
if key in _ENUMS and str(value).lower() not in _ENUMS[key]:
|
|
||||||
return {"error": f"{key} must be one of: {', '.join(_ENUMS[key])}.", "exit_code": 1}
|
|
||||||
s = load_settings()
|
|
||||||
s[key] = value
|
|
||||||
if key in {"default_model", "research_model", "utility_model", "task_model", "vision_model", "image_model"}:
|
|
||||||
resolved = _endpoint_model_from_cache(str(value))
|
|
||||||
if resolved:
|
|
||||||
prefix = key[:-6]
|
|
||||||
s[f"{prefix}_endpoint_id"] = resolved["endpoint_id"]
|
|
||||||
s[key] = resolved["model"]
|
|
||||||
value = resolved["model"]
|
|
||||||
save_settings(s)
|
|
||||||
if key.endswith("_model") and s.get(f"{key[:-6]}_endpoint_id"):
|
|
||||||
return {"response": f"Set {key} = {value} (endpoint {s.get(f'{key[:-6]}_endpoint_id')}).", "exit_code": 0}
|
|
||||||
return {"response": f"Set {key} = {value}.", "exit_code": 0}
|
|
||||||
|
|
||||||
elif action == "delete" or action == "reset":
|
|
||||||
key = _resolve(args.get("key", ""))
|
|
||||||
if key not in DEFAULT_SETTINGS:
|
|
||||||
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
|
|
||||||
if _is_secret(key):
|
|
||||||
return {"response": f"'{key}' is a credential — reset it in the panel.", "exit_code": 0}
|
|
||||||
s = load_settings()
|
|
||||||
s[key] = DEFAULT_SETTINGS[key]
|
|
||||||
save_settings(s)
|
|
||||||
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
|
||||||
|
|
||||||
elif action in ("disable_tool", "enable_tool", "list_tools"):
|
|
||||||
# Tool-toggle actions. These edit settings.json:disabled_tools
|
|
||||||
# (the global list read on every chat request) rather than
|
|
||||||
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
|
||||||
# "search" -> "web_search", "browser" -> "builtin_browser",
|
|
||||||
# "documents" -> the document tool set, "memory" ->
|
|
||||||
# manage_memory, etc.
|
|
||||||
from src.settings import get_setting, save_settings, load_settings
|
|
||||||
_ALIASES = {
|
|
||||||
"shell": ["bash"],
|
|
||||||
"terminal": ["bash"],
|
|
||||||
"search": ["web_search", "web_fetch"],
|
|
||||||
"web": ["web_search", "web_fetch"],
|
|
||||||
"browser": ["builtin_browser"],
|
|
||||||
"documents": ["create_document", "edit_document", "update_document", "suggest_document"],
|
|
||||||
"doc": ["create_document", "edit_document", "update_document", "suggest_document"],
|
|
||||||
"memory": ["manage_memory"],
|
|
||||||
"skills": ["manage_skills"],
|
|
||||||
"images": ["generate_image"],
|
|
||||||
"image": ["generate_image"],
|
|
||||||
"tasks": ["manage_tasks"],
|
|
||||||
"notes": ["manage_notes"],
|
|
||||||
"calendar": ["manage_calendar"],
|
|
||||||
"email": ["mcp__email__list_emails", "mcp__email__read_email", "mcp__email__send_email"],
|
|
||||||
"research": ["web_search", "web_fetch"], # research is a per-request flag, not a tool — closest analog
|
|
||||||
}
|
|
||||||
|
|
||||||
if action == "list_tools":
|
|
||||||
current = get_setting("disabled_tools", []) or []
|
|
||||||
return {
|
|
||||||
"response": (
|
|
||||||
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
|
||||||
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
|
||||||
"memory, skills, images, tasks, notes, calendar, email."
|
|
||||||
),
|
|
||||||
"disabled": list(current),
|
|
||||||
"exit_code": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
|
|
||||||
if not tool_name:
|
|
||||||
return {"error": "tool name required (e.g. 'shell', 'search', 'bash')", "exit_code": 1}
|
|
||||||
targets = _ALIASES.get(tool_name, [tool_name])
|
|
||||||
|
|
||||||
settings = load_settings()
|
|
||||||
current = list(settings.get("disabled_tools") or [])
|
|
||||||
before = set(current)
|
|
||||||
if action == "disable_tool":
|
|
||||||
for t in targets:
|
|
||||||
if t not in current:
|
|
||||||
current.append(t)
|
|
||||||
else: # enable_tool
|
|
||||||
current = [t for t in current if t not in targets]
|
|
||||||
after = set(current)
|
|
||||||
settings["disabled_tools"] = current
|
|
||||||
save_settings(settings)
|
|
||||||
|
|
||||||
verb = "Disabled" if action == "disable_tool" else "Enabled"
|
|
||||||
changed = sorted(after.symmetric_difference(before))
|
|
||||||
return {
|
|
||||||
"response": (
|
|
||||||
f"{verb} {tool_name} ({', '.join(targets)}). "
|
|
||||||
f"Now disabled: {', '.join(current) if current else '(none)'}."
|
|
||||||
),
|
|
||||||
"changed": changed,
|
|
||||||
"disabled": list(current),
|
|
||||||
"exit_code": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
else:
|
|
||||||
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"manage_settings error: {e}")
|
|
||||||
return {"error": str(e), "exit_code": 1}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# API call tool
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def do_api_call(content: str) -> Dict:
|
async def do_api_call(content: str) -> Dict:
|
||||||
"""Execute an API call to a registered integration."""
|
"""Execute an API call to a registered integration."""
|
||||||
from src.integrations import execute_api_call, load_integrations
|
from src.integrations import execute_api_call, load_integrations
|
||||||
@@ -3452,7 +2669,7 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
|
|||||||
host_only = host.split("@", 1)[-1] if host else "localhost"
|
host_only = host.split("@", 1)[-1] if host else "localhost"
|
||||||
endpoint_url = f"http://{host_only}:{int(port)}/v1"
|
endpoint_url = f"http://{host_only}:{int(port)}/v1"
|
||||||
try:
|
try:
|
||||||
from src.tool_implementations import do_manage_endpoints # avoid forward ref issues
|
from src.agent_tools.admin_tools import do_manage_endpoints # moved in #3629
|
||||||
except Exception:
|
except Exception:
|
||||||
do_manage_endpoints = None
|
do_manage_endpoints = None
|
||||||
if do_manage_endpoints is not None:
|
if do_manage_endpoints is not None:
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
|||||||
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
|
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
|
||||||
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
||||||
"search_chats": "Search past session transcripts across chats.",
|
"search_chats": "Search past session transcripts across chats.",
|
||||||
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
|
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Omit `multi`/keep it false unless the question explicitly permits choosing multiple options. Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
|
||||||
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
|
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
|
||||||
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
||||||
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
||||||
|
|||||||
+84
-1
@@ -308,6 +308,88 @@ def _parse_misfenced_web_lookup(content: str) -> Optional[ToolBlock]:
|
|||||||
return ToolBlock("web_fetch", url)
|
return ToolBlock("web_fetch", url)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_misfenced_read_file_lookup(content: str, *, allow_shell_style: bool = False) -> Optional[ToolBlock]:
|
||||||
|
"""Recover simple read_file calls wrapped in python/bash fences."""
|
||||||
|
stripped = content.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = ast.parse(stripped, mode="exec")
|
||||||
|
except SyntaxError:
|
||||||
|
module = None
|
||||||
|
if module and len(module.body) == 1 and isinstance(module.body[0], ast.Expr):
|
||||||
|
call = module.body[0].value
|
||||||
|
if isinstance(call, ast.Call) and isinstance(call.func, ast.Name):
|
||||||
|
if call.func.id.lower() != "read_file" or len(call.args) > 1:
|
||||||
|
return None
|
||||||
|
args = {}
|
||||||
|
if call.args:
|
||||||
|
path = _literal_string(call.args[0])
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
args["path"] = path
|
||||||
|
allowed = {"path", "file", "file_path", "offset", "limit"}
|
||||||
|
for keyword in call.keywords:
|
||||||
|
if keyword.arg not in allowed:
|
||||||
|
return None
|
||||||
|
key = "path" if keyword.arg in ("file", "file_path") else keyword.arg
|
||||||
|
if key == "path":
|
||||||
|
path = _literal_string(keyword.value)
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
args["path"] = path
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = ast.literal_eval(keyword.value)
|
||||||
|
except (ValueError, SyntaxError, TypeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(value, int) or value < 0:
|
||||||
|
return None
|
||||||
|
args[key] = value
|
||||||
|
if not args.get("path"):
|
||||||
|
return None
|
||||||
|
from src.tool_schemas import function_call_to_tool_block
|
||||||
|
return function_call_to_tool_block("read_file", json.dumps(args))
|
||||||
|
|
||||||
|
if not allow_shell_style:
|
||||||
|
return None
|
||||||
|
lines = [line.strip() for line in stripped.splitlines() if line.strip()]
|
||||||
|
if len(lines) != 1:
|
||||||
|
return None
|
||||||
|
match = re.fullmatch(r"read_file\s+(.+)", lines[0], re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
path = match.group(1).strip()
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
if path.startswith("{"):
|
||||||
|
try:
|
||||||
|
args = json.loads(path)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
return None
|
||||||
|
normalized = {}
|
||||||
|
raw_path = args.get("path") or args.get("file") or args.get("file_path")
|
||||||
|
if isinstance(raw_path, str) and raw_path.strip():
|
||||||
|
normalized["path"] = raw_path.strip()
|
||||||
|
for key in ("offset", "limit"):
|
||||||
|
value = args.get(key)
|
||||||
|
if isinstance(value, int) and value >= 0:
|
||||||
|
normalized[key] = value
|
||||||
|
if not normalized.get("path"):
|
||||||
|
return None
|
||||||
|
from src.tool_schemas import function_call_to_tool_block
|
||||||
|
return function_call_to_tool_block("read_file", json.dumps(normalized))
|
||||||
|
if len(path) >= 2 and path[0] == path[-1] and path[0] in "'\"":
|
||||||
|
path = path[1:-1].strip()
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
return ToolBlock("read_file", path)
|
||||||
|
|
||||||
|
|
||||||
def _coerce_raw_web_query(value) -> Optional[str]:
|
def _coerce_raw_web_query(value) -> Optional[str]:
|
||||||
if isinstance(value, str) and value.strip():
|
if isinstance(value, str) and value.strip():
|
||||||
return value.strip()
|
return value.strip()
|
||||||
@@ -704,7 +786,8 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
# _XML_INVOKE_RE's \w+ can't match would otherwise be executed as code.
|
# _XML_INVOKE_RE's \w+ can't match would otherwise be executed as code.
|
||||||
continue
|
continue
|
||||||
if tag in ("python", "bash"):
|
if tag in ("python", "bash"):
|
||||||
block = _parse_misfenced_web_lookup(content)
|
block = (_parse_misfenced_web_lookup(content)
|
||||||
|
or _parse_misfenced_read_file_lookup(content, allow_shell_style=(tag == "bash")))
|
||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
continue
|
continue
|
||||||
|
|||||||
+8
-2
@@ -467,7 +467,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"question": {"type": "string", "description": "The question to ask. Be specific and self-contained."},
|
"question": {"type": "string", "description": "The question to ask. Be specific and self-contained."},
|
||||||
"options": {
|
"options": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "2-6 mutually exclusive choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.",
|
"description": "2-6 choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -477,7 +477,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"required": ["label"]
|
"required": ["label"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"multi": {"type": "boolean", "description": "Set true to let the user select multiple options instead of one. Default false."}
|
"multi": {"type": "boolean", "description": "Set true ONLY when the question explicitly allows choosing more than one option. Otherwise omit it or set false. Default false."}
|
||||||
},
|
},
|
||||||
"required": ["question", "options"]
|
"required": ["question", "options"]
|
||||||
}
|
}
|
||||||
@@ -1406,6 +1406,12 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
|||||||
content = json.dumps(args)
|
content = json.dumps(args)
|
||||||
elif tool_type == "ask_teacher":
|
elif tool_type == "ask_teacher":
|
||||||
content = args.get("model", "auto") + "\n" + args.get("problem", "")
|
content = args.get("model", "auto") + "\n" + args.get("problem", "")
|
||||||
|
elif tool_type == "ask_user":
|
||||||
|
# Keep user-facing labels readable in the tool trace. The outer SSE
|
||||||
|
# JSON encoder will escape them for transport and JSON.parse restores
|
||||||
|
# them once; pre-escaping here caused literal ``\u00f1`` sequences to
|
||||||
|
# remain visible in the debug panel.
|
||||||
|
content = json.dumps(args, ensure_ascii=False)
|
||||||
else:
|
else:
|
||||||
content = json.dumps(args)
|
content = json.dumps(args)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ src.constants which imports nothing from src). Adding a project import here
|
|||||||
will reintroduce the circular dependency that this module exists to break.
|
will reintroduce the circular dependency that this module exists to break.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from src.constants import MAX_OUTPUT_CHARS
|
from src.constants import MAX_OUTPUT_CHARS
|
||||||
|
|
||||||
_mcp_manager = None
|
_mcp_manager = None
|
||||||
@@ -37,3 +39,36 @@ def _truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
|
|||||||
if len(text) > limit:
|
if len(text) > limit:
|
||||||
return text[:limit] + f"\n... (truncated, {len(text)} chars total)"
|
return text[:limit] + f"\n... (truncated, {len(text)} chars total)"
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tool_args(content):
|
||||||
|
"""Parse a tool-call argument blob.
|
||||||
|
|
||||||
|
Accepts either a JSON string or an already-decoded dict. Unwraps the
|
||||||
|
common `{"body": {...}}` envelope that smaller models emit when they
|
||||||
|
read tool descriptions like "Body is JSON: {...}" literally and
|
||||||
|
pass `body` as a field name rather than treating it as a noun.
|
||||||
|
|
||||||
|
Returns a dict on success, raises ValueError on bad JSON.
|
||||||
|
"""
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(content) if content.strip() else {}
|
||||||
|
except (json.JSONDecodeError, TypeError) as e:
|
||||||
|
raise ValueError(str(e))
|
||||||
|
elif isinstance(content, dict):
|
||||||
|
args = content
|
||||||
|
else:
|
||||||
|
args = {}
|
||||||
|
# Unwrap {"body": {...}} envelope, but only if `body` is the sole key
|
||||||
|
# and points at a dict. We don't want to clobber a legitimate `body`
|
||||||
|
# field on tools where it's a real arg (e.g. send_email body text).
|
||||||
|
if (
|
||||||
|
isinstance(args, dict)
|
||||||
|
and len(args) == 1
|
||||||
|
and "body" in args
|
||||||
|
and isinstance(args["body"], dict)
|
||||||
|
and "action" in args["body"] # extra safety: only unwrap if the inner dict looks like a tool call
|
||||||
|
):
|
||||||
|
args = args["body"]
|
||||||
|
return args
|
||||||
|
|||||||
+5
-3
@@ -91,7 +91,7 @@ async function _createDirectChatFromPreferredModel() {
|
|||||||
if (!sessionModule) return false;
|
if (!sessionModule) return false;
|
||||||
|
|
||||||
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
|
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
|
||||||
if (pending && pending.url && pending.modelId) {
|
if (pending && pending.url && pending.modelId && pending.endpointId) {
|
||||||
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
|
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ async function _createDirectChatFromPreferredModel() {
|
|||||||
const sessions = sessionModule.getSessions();
|
const sessions = sessionModule.getSessions();
|
||||||
const currentId = sessionModule.getCurrentSessionId();
|
const currentId = sessionModule.getCurrentSessionId();
|
||||||
const current = sessions.find(s => s.id === currentId);
|
const current = sessions.find(s => s.id === currentId);
|
||||||
if (current && current.endpoint_url && current.model) {
|
if (current && current.endpoint_url && current.model && current.endpoint_id) {
|
||||||
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2418,7 +2418,7 @@ function initializeEventListeners() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Keys hidden by default on first run (no localStorage yet)
|
// Keys hidden by default on first run (no localStorage yet)
|
||||||
const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn', 'text-emojis']);
|
const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
|
||||||
|
|
||||||
// Keys that need admin to toggle off (reserved for future use)
|
// Keys that need admin to toggle off (reserved for future use)
|
||||||
const UI_VIS_ADMIN_ONLY = new Set([]);
|
const UI_VIS_ADMIN_ONLY = new Set([]);
|
||||||
@@ -2451,6 +2451,8 @@ function initializeEventListeners() {
|
|||||||
applyTextEmojis(state['text-emojis'] === true);
|
applyTextEmojis(state['text-emojis'] === true);
|
||||||
// Hide thinking sections toggle (show-thinking: checked=show, unchecked=hide)
|
// Hide thinking sections toggle (show-thinking: checked=show, unchecked=hide)
|
||||||
document.body.classList.toggle('hide-thinking', state['show-thinking'] === false);
|
document.body.classList.toggle('hide-thinking', state['show-thinking'] === false);
|
||||||
|
// Fullwidth chat toggle (chat-fullwidth: checked=fullwidth, unchecked=big-padding
|
||||||
|
document.body.classList.toggle('fullwidth-chat', state['chat-fullwidth'] === true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rearrange toggles in session/model sort dropdowns
|
// Rearrange toggles in session/model sort dropdowns
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+31
-2
@@ -76,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
// Apply font early
|
// Apply font early
|
||||||
if (t && t.font) {
|
if (t && t.font) {
|
||||||
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif"};
|
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif",opendyslexic:"'OpenDyslexic', sans-serif"};
|
||||||
if (fm[t.font]) { s.setProperty('--font-family', fm[t.font]); }
|
if (fm[t.font]) { s.setProperty('--font-family', fm[t.font]); }
|
||||||
else { s.setProperty('--font-family', "'" + t.font.replace(/'/g,'') + "', sans-serif"); }
|
else { s.setProperty('--font-family', "'" + t.font.replace(/'/g,'') + "', sans-serif"); }
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,12 @@
|
|||||||
if (t && t.density && t.density !== 'comfortable') {
|
if (t && t.density && t.density !== 'comfortable') {
|
||||||
document.documentElement.classList.add('density-' + t.density);
|
document.documentElement.classList.add('density-' + t.density);
|
||||||
}
|
}
|
||||||
|
// Apply UI text-size scale early (global accessibility pref, independent
|
||||||
|
// of the active theme) so there's no flash on load.
|
||||||
|
try {
|
||||||
|
var _us = localStorage.getItem('odysseus-ui-scale');
|
||||||
|
if (_us && _us !== '100') document.documentElement.classList.add('ui-scale-' + _us);
|
||||||
|
} catch(e){}
|
||||||
// Apply background pattern on body once available
|
// Apply background pattern on body once available
|
||||||
if (t && t.bgPattern && t.bgPattern !== 'none') {
|
if (t && t.bgPattern && t.bgPattern !== 'none') {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
@@ -581,6 +587,7 @@
|
|||||||
<option value="mono">Monospace</option>
|
<option value="mono">Monospace</option>
|
||||||
<option value="sans">Sans-serif</option>
|
<option value="sans">Sans-serif</option>
|
||||||
<option value="serif">Serif</option>
|
<option value="serif">Serif</option>
|
||||||
|
<option value="opendyslexic">OpenDyslexic (dyslexia-friendly)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-fd-group">
|
<div class="theme-fd-group">
|
||||||
@@ -591,6 +598,13 @@
|
|||||||
<option value="spacious">Spacious</option>
|
<option value="spacious">Spacious</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="theme-fd-group">
|
||||||
|
<label class="theme-fd-label">Text size</label>
|
||||||
|
<select id="theme-text-size-select" class="theme-fd-select" aria-label="Text size">
|
||||||
|
<option value="100">Default</option>
|
||||||
|
<option value="125">Larger</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="theme-fd-group" id="theme-frosted-group">
|
<div class="theme-fd-group" id="theme-frosted-group">
|
||||||
<label class="theme-fd-label" for="theme-frosted-toggle">Frosted</label>
|
<label class="theme-fd-label" for="theme-frosted-toggle">Frosted</label>
|
||||||
<label class="admin-switch" style="margin-top:4px;">
|
<label class="admin-switch" style="margin-top:4px;">
|
||||||
@@ -1318,7 +1332,7 @@
|
|||||||
|
|
||||||
<!-- Cookbook Modal -->
|
<!-- Cookbook Modal -->
|
||||||
<div id="cookbook-modal" class="modal hidden">
|
<div id="cookbook-modal" class="modal hidden">
|
||||||
<div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); height: 94vh; max-height: 94vh; background: var(--bg);">
|
<div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); background: var(--bg);">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 style="margin:0;margin-right:auto"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>Cookbook</h4>
|
<h4 style="margin:0;margin-right:auto"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>Cookbook</h4>
|
||||||
<button class="close-btn" id="close-cookbook-modal" aria-label="Close cookbook">✖</button>
|
<button class="close-btn" id="close-cookbook-modal" aria-label="Close cookbook">✖</button>
|
||||||
@@ -1806,6 +1820,11 @@
|
|||||||
<span class="vis-label">Session Header <span class="vis-hint">Model name & export above chat</span></span>
|
<span class="vis-label">Session Header <span class="vis-hint">Model name & export above chat</span></span>
|
||||||
<input type="checkbox" checked data-ui-key="chat-meta"><span class="vis-switch"></span>
|
<input type="checkbox" checked data-ui-key="chat-meta"><span class="vis-switch"></span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="vis-row">
|
||||||
|
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16"/><path d="M4 10h8"/></svg></span>
|
||||||
|
<span class="vis-label">Full-width chat <span class="vis-hint">Use the full window width (desktop)</span></span>
|
||||||
|
<input type="checkbox" data-ui-key="chat-fullwidth"><span class="vis-switch"></span>
|
||||||
|
</label>
|
||||||
<label class="vis-row">
|
<label class="vis-row">
|
||||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3v2m0 14v2m-7-9H3m18 0h-2m-1.5-6.5L16 7m-8-1.5L6.5 7m11 11l-1.5-1.5M8 18l-1.5 1.5"/><circle cx="12" cy="12" r="4"/></svg></span>
|
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3v2m0 14v2m-7-9H3m18 0h-2m-1.5-6.5L16 7m-8-1.5L6.5 7m11 11l-1.5-1.5M8 18l-1.5 1.5"/><circle cx="12" cy="12" r="4"/></svg></span>
|
||||||
<span class="vis-label">Welcome Message <span class="vis-hint">Logo & tips on empty chat</span></span>
|
<span class="vis-label">Welcome Message <span class="vis-hint">Logo & tips on empty chat</span></span>
|
||||||
@@ -2046,6 +2065,16 @@
|
|||||||
<label class="admin-switch"><input type="checkbox" id="adm-signupToggle"><span class="admin-slider"></span></label>
|
<label class="admin-switch"><input type="checkbox" id="adm-signupToggle"><span class="admin-slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="admin-card">
|
||||||
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 15v3m-3-3h6M12 3v2m0 16v-2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M3 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/><circle cx="12" cy="12" r="3"/></svg>Model Defaults</h2>
|
||||||
|
<div class="admin-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="admin-toggle-label">Share defaults with users</div>
|
||||||
|
<div class="admin-toggle-sub">When on, users without a personal default inherit the global default model (only if those models are allowed for them).</div>
|
||||||
|
</div>
|
||||||
|
<label class="admin-switch"><input type="checkbox" id="adm-shareDefaultsToggle"><span class="admin-slider"></span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Users</h2>
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Users</h2>
|
||||||
<div id="adm-userList"><div class="admin-empty">Loading...</div></div>
|
<div id="adm-userList"><div class="admin-empty">Loading...</div></div>
|
||||||
|
|||||||
+43
-4
@@ -343,6 +343,28 @@ function initSignupToggle() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initShareDefaultsToggle() {
|
||||||
|
const toggle = el('adm-shareDefaultsToggle');
|
||||||
|
fetch('/api/auth/settings', { credentials: 'same-origin' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { toggle.checked = !!d.share_defaults_with_users; })
|
||||||
|
.catch(e => console.warn('Settings fetch failed:', e));
|
||||||
|
toggle.addEventListener('change', async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ share_defaults_with_users: toggle.checked }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
toggle.checked = !!data.share_defaults_with_users;
|
||||||
|
} catch (e) {
|
||||||
|
toggle.checked = !toggle.checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function initAddUser() {
|
function initAddUser() {
|
||||||
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
||||||
.then(r => r.ok ? r.json() : null)
|
.then(r => r.ok ? r.json() : null)
|
||||||
@@ -1581,8 +1603,8 @@ function initEndpointForm() {
|
|||||||
wrap.style.cssText = 'display:flex;align-items:center;padding:8px 0;';
|
wrap.style.cssText = 'display:flex;align-items:center;padding:8px 0;';
|
||||||
wrap.appendChild(wp.element);
|
wrap.appendChild(wp.element);
|
||||||
const txt = document.createElement('span');
|
const txt = document.createElement('span');
|
||||||
txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...';
|
txt.textContent = 'Scanning ports 8000-8020, 8080, 1234, 11434, and 11435 for model servers...';
|
||||||
txt.style.cssText = 'opacity:0.7;';
|
txt.style.cssText = 'font-size:12px;opacity:0.7;';
|
||||||
wrap.appendChild(txt);
|
wrap.appendChild(txt);
|
||||||
msg.appendChild(wrap);
|
msg.appendChild(wrap);
|
||||||
discoverBtn._wp = wp;
|
discoverBtn._wp = wp;
|
||||||
@@ -1597,12 +1619,24 @@ function initEndpointForm() {
|
|||||||
} else {
|
} else {
|
||||||
// Auto-add each discovered endpoint. Server dedupes on base_url
|
// Auto-add each discovered endpoint. Server dedupes on base_url
|
||||||
// and returns `existing: true` for already-registered ones.
|
// and returns `existing: true` for already-registered ones.
|
||||||
|
// Map fingerprinted provider IDs to friendly display names.
|
||||||
|
const _PROVIDER_DISPLAY = {
|
||||||
|
llamacpp: 'llama.cpp', lmstudio: 'LM Studio', vllm: 'vLLM',
|
||||||
|
ollama: 'Ollama',
|
||||||
|
};
|
||||||
let added = 0;
|
let added = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
||||||
|
const providerDisplay = _PROVIDER_DISPLAY[item.provider] || null;
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('base_url', base);
|
fd.append('base_url', base);
|
||||||
|
if (providerDisplay) {
|
||||||
|
// Use "Provider (host:port)" so the endpoint is immediately
|
||||||
|
// identifiable in the list, e.g. "llama.cpp (localhost:8080)".
|
||||||
|
const hostPart = base.replace(/^https?:\/\//, '').split('/')[0];
|
||||||
|
fd.append('name', `${providerDisplay} (${hostPart})`);
|
||||||
|
}
|
||||||
fd.append('endpoint_kind', 'local');
|
fd.append('endpoint_kind', 'local');
|
||||||
fd.append('model_refresh_mode', 'auto');
|
fd.append('model_refresh_mode', 'auto');
|
||||||
fd.append('skip_probe', 'false');
|
fd.append('skip_probe', 'false');
|
||||||
@@ -1616,7 +1650,12 @@ function initEndpointForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const totalModels = items.reduce((n, i) => n + (i.models ? i.models.length : 0), 0);
|
const totalModels = items.reduce((n, i) => n + (i.models ? i.models.length : 0), 0);
|
||||||
const parts = [`Found ${items.length} server${items.length !== 1 ? 's' : ''} with ${totalModels} model${totalModels !== 1 ? 's' : ''}`];
|
const serverNames = items.map(i =>
|
||||||
|
(_PROVIDER_DISPLAY[i.provider] || i.url.replace(/^https?:\/\//, '').split('/')[0])
|
||||||
|
);
|
||||||
|
const parts = [
|
||||||
|
`Found ${items.length} server${items.length !== 1 ? 's' : ''} (${serverNames.join(', ')}) with ${totalModels} model${totalModels !== 1 ? 's' : ''}`,
|
||||||
|
];
|
||||||
if (added) parts.push(`added ${added} new`);
|
if (added) parts.push(`added ${added} new`);
|
||||||
if (skipped) parts.push(`${skipped} already added`);
|
if (skipped) parts.push(`${skipped} already added`);
|
||||||
msg.innerHTML = parts.join(' — ');
|
msg.innerHTML = parts.join(' — ');
|
||||||
@@ -2986,7 +3025,7 @@ function initLogsView() {
|
|||||||
function initAll() {
|
function initAll() {
|
||||||
modalEl = el('settings-modal');
|
modalEl = el('settings-modal');
|
||||||
const inits = [
|
const inits = [
|
||||||
initSignupToggle, initAddUser, initEndpointForm, initMcpForm,
|
initSignupToggle, initShareDefaultsToggle, initAddUser, initEndpointForm, initMcpForm,
|
||||||
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
|
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
|
||||||
() => settingsModule.initIntegrations()
|
() => settingsModule.initIntegrations()
|
||||||
];
|
];
|
||||||
|
|||||||
+10
-9
@@ -5,6 +5,7 @@
|
|||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
|
import { topPortalZ } from './toolWindowZOrder.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
import { attachColorPicker } from './colorPicker.js';
|
import { attachColorPicker } from './colorPicker.js';
|
||||||
import { bindMenuDismiss } from './escMenuStack.js';
|
import { bindMenuDismiss } from './escMenuStack.js';
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
|
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
|
||||||
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
||||||
_trashIcon, _moreIcon, _bellIcon,
|
_trashIcon, _moreIcon, _bellIcon,
|
||||||
_isCalBgImage, _calBgImageUrl, _calBgCss,
|
_isCalBgImage, _calBgImageUrl, _calBgCss, _cssUrlEscape,
|
||||||
_calReadableTextColor,
|
_calReadableTextColor,
|
||||||
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
|
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
|
||||||
} from './calendar/utils.js';
|
} from './calendar/utils.js';
|
||||||
@@ -413,8 +414,8 @@ function _calEventFg(ev) {
|
|||||||
// Returns '' for normal solid-color events.
|
// Returns '' for normal solid-color events.
|
||||||
function _calItemBgStyle(ev) {
|
function _calItemBgStyle(ev) {
|
||||||
if (!_isCalBgImage(ev.color)) return '';
|
if (!_isCalBgImage(ev.color)) return '';
|
||||||
const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
const url = _calBgImageUrl(ev.color);
|
||||||
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${url}'); background-size: cover; background-position: center;`;
|
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${_cssUrlEscape(url)}'); background-size: cover; background-position: center;`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _todayCount() {
|
function _todayCount() {
|
||||||
@@ -470,7 +471,7 @@ function _showEventMoreMenu(ev, anchor) {
|
|||||||
dropdown.className = 'cal-event-dropdown';
|
dropdown.className = 'cal-event-dropdown';
|
||||||
let closeMenu = () => dropdown.remove();
|
let closeMenu = () => dropdown.remove();
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`;
|
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`;
|
||||||
|
|
||||||
const _item = (icon, label, onClick, danger) => {
|
const _item = (icon, label, onClick, danger) => {
|
||||||
const it = document.createElement('div');
|
const it = document.createElement('div');
|
||||||
@@ -1260,8 +1261,8 @@ async function _renderWeek() {
|
|||||||
// events keep the original tinted treatment.
|
// events keep the original tinted treatment.
|
||||||
let bgDecl;
|
let bgDecl;
|
||||||
if (_isCalBgImage(ev.color)) {
|
if (_isCalBgImage(ev.color)) {
|
||||||
const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
const _url = _calBgImageUrl(ev.color);
|
||||||
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_url}'); background-size: cover; background-position: center;`;
|
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_cssUrlEscape(_url)}'); background-size: cover; background-position: center;`;
|
||||||
} else {
|
} else {
|
||||||
bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`;
|
bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`;
|
||||||
}
|
}
|
||||||
@@ -2853,7 +2854,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
let bg;
|
let bg;
|
||||||
if (isCustom) {
|
if (isCustom) {
|
||||||
const url = _calBgImageUrl(cur);
|
const url = _calBgImageUrl(cur);
|
||||||
bg = url ? `center/cover no-repeat url('${url}')` : _CAL_CUSTOM_GRADIENT;
|
bg = url ? `center/cover no-repeat url('${_cssUrlEscape(url)}')` : _CAL_CUSTOM_GRADIENT;
|
||||||
} else {
|
} else {
|
||||||
bg = c.hex || 'var(--border)';
|
bg = c.hex || 'var(--border)';
|
||||||
}
|
}
|
||||||
@@ -2928,7 +2929,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
// stays readable. Chrome accent falls back to the theme accent.
|
// stays readable. Chrome accent falls back to the theme accent.
|
||||||
const url = _calBgImageUrl(hex);
|
const url = _calBgImageUrl(hex);
|
||||||
_formCard.style.setProperty('--ev-color', 'var(--accent)');
|
_formCard.style.setProperty('--ev-color', 'var(--accent)');
|
||||||
_formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${url.replace(/'/g, "\\'")}')`;
|
_formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${_cssUrlEscape(url)}')`;
|
||||||
_formCard.style.backgroundSize = 'cover';
|
_formCard.style.backgroundSize = 'cover';
|
||||||
_formCard.style.backgroundPosition = 'center';
|
_formCard.style.backgroundPosition = 'center';
|
||||||
_formCard.classList.add('cal-form-bg-image');
|
_formCard.classList.add('cal-form-bg-image');
|
||||||
@@ -2950,7 +2951,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
if (!url) return;
|
if (!url) return;
|
||||||
const sentinel = 'bg:' + url;
|
const sentinel = 'bg:' + url;
|
||||||
dot.dataset.color = sentinel;
|
dot.dataset.color = sentinel;
|
||||||
dot.style.background = `center/cover no-repeat url('${url}')`;
|
dot.style.background = `center/cover no-repeat url('${_cssUrlEscape(url)}')`;
|
||||||
document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active'));
|
document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active'));
|
||||||
dot.classList.add('active');
|
dot.classList.add('active');
|
||||||
_applyFormTint(sentinel);
|
_applyFormTint(sentinel);
|
||||||
|
|||||||
@@ -65,13 +65,25 @@ export function _calBgImageUrl(c) {
|
|||||||
return _isCalBgImage(c) ? c.slice(3) : '';
|
return _isCalBgImage(c) ? c.slice(3) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escape a value for safe embedding inside a single-quoted CSS `url('...')`.
|
||||||
|
// Backslashes MUST be escaped first: otherwise a trailing/embedded `\` in the
|
||||||
|
// (CalDAV-syncable, untrusted) bg-image URL would escape the closing quote we
|
||||||
|
// add for `'` and let the value break out of the string (CodeQL
|
||||||
|
// js/incomplete-sanitization). `"` is percent-encoded for good measure.
|
||||||
|
export function _cssUrlEscape(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/'/g, "\\'")
|
||||||
|
.replace(/"/g, '%22');
|
||||||
|
}
|
||||||
|
|
||||||
// Returns a value safe to drop into `style="background:..."`. Falls back to
|
// Returns a value safe to drop into `style="background:..."`. Falls back to
|
||||||
// the calendar default for bg-image events in spots where an image would be
|
// the calendar default for bg-image events in spots where an image would be
|
||||||
// too small to render usefully (small grid dots, multi-day bars).
|
// too small to render usefully (small grid dots, multi-day bars).
|
||||||
export function _calBgCss(c, fallback) {
|
export function _calBgCss(c, fallback) {
|
||||||
if (_isCalBgImage(c)) {
|
if (_isCalBgImage(c)) {
|
||||||
const u = _calBgImageUrl(c);
|
const u = _calBgImageUrl(c);
|
||||||
return u ? `center/cover no-repeat url('${u.replace(/'/g, "\\'")}')` : (fallback || 'var(--accent)');
|
return u ? `center/cover no-repeat url('${_cssUrlEscape(u)}')` : (fallback || 'var(--accent)');
|
||||||
}
|
}
|
||||||
return c || fallback || 'var(--accent)';
|
return c || fallback || 'var(--accent)';
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-142
@@ -12,7 +12,6 @@ import chatRenderer from './chatRenderer.js';
|
|||||||
import chatStream from './chatStream.js';
|
import chatStream from './chatStream.js';
|
||||||
import { addAITTSButton } from './tts-ai.js';
|
import { addAITTSButton } from './tts-ai.js';
|
||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
import { svgifyEmoji } from './markdown.js';
|
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import presetsModule from './presets.js';
|
import presetsModule from './presets.js';
|
||||||
import fileHandlerModule from './fileHandler.js';
|
import fileHandlerModule from './fileHandler.js';
|
||||||
@@ -1921,6 +1920,23 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
_chatBox.appendChild(note);
|
_chatBox.appendChild(note);
|
||||||
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
|
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
|
||||||
}
|
}
|
||||||
|
} else if (json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted') {
|
||||||
|
// A loop guard ended the turn — surface why so it isn't mistaken
|
||||||
|
// for a clean completion or a silent stall.
|
||||||
|
const _chatBox = document.getElementById('chat-history');
|
||||||
|
if (!_isBg && _chatBox) {
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.className = 'stopped-indicator loop-guard-stop';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'rounds-exhausted-label';
|
||||||
|
label.textContent = json.message ||
|
||||||
|
(json.type === 'loop_breaker_triggered'
|
||||||
|
? 'Stopped by the loop-breaker (no new progress).'
|
||||||
|
: 'Stopped: announced an action but never called the tool.');
|
||||||
|
note.appendChild(label);
|
||||||
|
_chatBox.appendChild(note);
|
||||||
|
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
|
||||||
|
}
|
||||||
} else if (json.type === 'model_actual') {
|
} else if (json.type === 'model_actual') {
|
||||||
if (!_isBg && holder) {
|
if (!_isBg && holder) {
|
||||||
holder._requestedModel = json.requested_model || holder._requestedModel || modelName;
|
holder._requestedModel = json.requested_model || holder._requestedModel || modelName;
|
||||||
@@ -2321,148 +2337,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
} else if (json.type === 'ask_user') {
|
} else if (json.type === 'ask_user') {
|
||||||
if (_isBg) continue;
|
if (_isBg) continue;
|
||||||
// The agent posed a multiple-choice question; the turn has ended.
|
// The agent posed a multiple-choice question; the turn has ended.
|
||||||
// Render clickable options at the bottom of the history. The
|
// Use the shared history renderer so the live and restored
|
||||||
// user's pick is sent as the next message and the agent resumes.
|
// versions have identical behavior.
|
||||||
_cancelThinkingTimer();
|
_cancelThinkingTimer();
|
||||||
_removeThinkingSpinner();
|
_removeThinkingSpinner();
|
||||||
const _aq = json.data || {};
|
chatRenderer.renderAskUserCard(json.data || {});
|
||||||
const _opts = Array.isArray(_aq.options) ? _aq.options : [];
|
|
||||||
if (_aq.question && _opts.length) {
|
|
||||||
const chatBox = document.getElementById('chat-history');
|
|
||||||
// Drop any prior unanswered card so only the latest shows.
|
|
||||||
chatBox.querySelectorAll('.ask-user-card').forEach(n => n.remove());
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'ask-user-card';
|
|
||||||
const multi = !!_aq.multi;
|
|
||||||
// Group the choices for assistive tech and label the group with
|
|
||||||
// the question (set below); make the card focusable so it can be
|
|
||||||
// moved to when it appears.
|
|
||||||
card.setAttribute('role', 'group');
|
|
||||||
card.tabIndex = -1;
|
|
||||||
// Render any emoji in agent-supplied text through the app's
|
|
||||||
// pipeline: escape, then svgify to monochrome theme-tinted
|
|
||||||
// glyphs (project rule: never colorful emoji; respects the
|
|
||||||
// "Text-only Emojis" setting like the rest of the chat).
|
|
||||||
const _emo = (s) => svgifyEmoji(uiModule.esc(String(s)));
|
|
||||||
|
|
||||||
// Header row holds the close (×) to dismiss the affordances and
|
|
||||||
// just type a reply instead.
|
|
||||||
const head = document.createElement('div');
|
|
||||||
head.className = 'ask-user-head';
|
|
||||||
const closeBtn = document.createElement('button');
|
|
||||||
closeBtn.type = 'button';
|
|
||||||
closeBtn.className = 'modal-close ask-user-close';
|
|
||||||
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
|
||||||
closeBtn.textContent = '×';
|
|
||||||
closeBtn.addEventListener('click', () => {
|
|
||||||
card.remove();
|
|
||||||
const mi = uiModule.el('message');
|
|
||||||
if (mi) mi.focus();
|
|
||||||
});
|
|
||||||
head.appendChild(closeBtn);
|
|
||||||
card.appendChild(head);
|
|
||||||
|
|
||||||
// Render the question inside the card so it's self-contained:
|
|
||||||
// some models call ask_user without first narrating the question
|
|
||||||
// as assistant text, in which case the card would otherwise show
|
|
||||||
// bare options with no prompt.
|
|
||||||
if (_aq.question) {
|
|
||||||
const q = document.createElement('div');
|
|
||||||
q.className = 'ask-user-question';
|
|
||||||
q.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
|
||||||
q.innerHTML = _emo(_aq.question);
|
|
||||||
card.appendChild(q);
|
|
||||||
// Label the choice group with the question for screen readers.
|
|
||||||
card.setAttribute('aria-labelledby', q.id);
|
|
||||||
} else {
|
|
||||||
card.setAttribute('aria-label', 'Question from the assistant');
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = document.createElement('div');
|
|
||||||
list.className = 'ask-user-options';
|
|
||||||
card.appendChild(list);
|
|
||||||
|
|
||||||
const _send = (text) => {
|
|
||||||
if (!text) return;
|
|
||||||
// Remove the card once answered — the choice is sent as a
|
|
||||||
// normal user message (and the question persists as the
|
|
||||||
// assistant text above), so the affordances are spent.
|
|
||||||
card.remove();
|
|
||||||
const mi = uiModule.el('message');
|
|
||||||
if (mi) mi.value = text;
|
|
||||||
const sb = document.querySelector('.send-btn');
|
|
||||||
if (sb) sb.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
_opts.forEach((opt, i) => {
|
|
||||||
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
|
|
||||||
if (!label) return;
|
|
||||||
const descr = (opt && opt.description) ? String(opt.description) : '';
|
|
||||||
const row = document.createElement(multi ? 'label' : 'button');
|
|
||||||
row.className = 'ask-user-option';
|
|
||||||
if (multi) {
|
|
||||||
const cb = document.createElement('input');
|
|
||||||
cb.type = 'checkbox';
|
|
||||||
cb.value = label;
|
|
||||||
row.appendChild(cb);
|
|
||||||
}
|
|
||||||
const txt = document.createElement('span');
|
|
||||||
txt.className = 'ask-user-option-label';
|
|
||||||
txt.innerHTML = _emo(label);
|
|
||||||
row.appendChild(txt);
|
|
||||||
if (descr) {
|
|
||||||
const d = document.createElement('span');
|
|
||||||
d.className = 'ask-user-option-desc';
|
|
||||||
d.innerHTML = _emo(descr);
|
|
||||||
row.appendChild(d);
|
|
||||||
}
|
|
||||||
if (!multi) {
|
|
||||||
row.type = 'button';
|
|
||||||
row.addEventListener('click', () => _send(label));
|
|
||||||
}
|
|
||||||
list.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Free-text "Other" — type a custom answer + send (Enter or →).
|
|
||||||
const other = document.createElement('div');
|
|
||||||
other.className = 'ask-user-other';
|
|
||||||
const otherInput = document.createElement('input');
|
|
||||||
otherInput.type = 'text';
|
|
||||||
otherInput.className = 'styled-prompt-input ask-user-other-input';
|
|
||||||
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
|
|
||||||
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
|
|
||||||
const otherSend = document.createElement('button');
|
|
||||||
otherSend.type = 'button';
|
|
||||||
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
|
|
||||||
otherSend.setAttribute('aria-label', 'Send answer');
|
|
||||||
otherSend.textContent = multi ? 'Send selection' : 'Send';
|
|
||||||
const _submit = () => {
|
|
||||||
const free = otherInput.value.trim();
|
|
||||||
if (multi) {
|
|
||||||
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map(c => c.value);
|
|
||||||
if (free) picked.push(free);
|
|
||||||
if (picked.length) _send(picked.join(', '));
|
|
||||||
} else if (free) {
|
|
||||||
_send(free);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
otherSend.addEventListener('click', _submit);
|
|
||||||
otherInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
||||||
e.preventDefault();
|
|
||||||
_submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
other.appendChild(otherInput);
|
|
||||||
other.appendChild(otherSend);
|
|
||||||
card.appendChild(other);
|
|
||||||
|
|
||||||
chatBox.appendChild(card);
|
|
||||||
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
// Move focus to the card so keyboard/screen-reader users land on
|
|
||||||
// the question + choices when it appears.
|
|
||||||
try { card.focus(); } catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (json.type === 'plan_update') {
|
} else if (json.type === 'plan_update') {
|
||||||
if (_isBg) continue;
|
if (_isBg) continue;
|
||||||
@@ -5019,7 +4898,16 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
if (!header) return;
|
if (!header) return;
|
||||||
const node = header.closest('.agent-thread-node');
|
const node = header.closest('.agent-thread-node');
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
node.classList.toggle('open');
|
const opened = node.classList.toggle('open');
|
||||||
|
if (opened) {
|
||||||
|
// Expanding the final tool trace can push a pending ask_user card below
|
||||||
|
// the viewport. Keep that immediately-adjacent prompt visible.
|
||||||
|
const thread = node.closest('.agent-thread');
|
||||||
|
const pendingCard = thread?.nextElementSibling;
|
||||||
|
if (pendingCard?.classList.contains('ask-user-card')) {
|
||||||
|
requestAnimationFrame(() => pendingCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
window.__odysseus_thread_click_bound = true;
|
window.__odysseus_thread_click_bound = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+191
-3
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
|
import { svgifyEmoji } from './markdown.js';
|
||||||
import { addAITTSButton } from './tts-ai.js';
|
import { addAITTSButton } from './tts-ai.js';
|
||||||
import { providerLogo, providerLabel } from './providers.js';
|
import { providerLogo, providerLabel } from './providers.js';
|
||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
@@ -406,8 +407,44 @@ function _openVisionEditor(att, userMsgEl) {
|
|||||||
|
|
||||||
// Tool call syntax patterns to strip from displayed text
|
// Tool call syntax patterns to strip from displayed text
|
||||||
const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi;
|
const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi;
|
||||||
// Only strip fenced tool-call blocks that look like structured invocations, not regular code examples
|
// Strip fenced tool-call blocks that look like structured invocations, not
|
||||||
const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi;
|
// regular code examples. The tool tags are NOT hard-coded here — they are the
|
||||||
|
// backend's authoritative TOOL_TAGS set, fetched once from GET /api/tools and
|
||||||
|
// built into EXEC_FENCE_RE at load. TOOL_TAGS (src/agent_tools/__init__.py) is
|
||||||
|
// thus the single source: the live-strip list can never drift from the backend
|
||||||
|
// or miss a future tool (#3993). bash/python are carved out on purpose — they
|
||||||
|
// are languages a user may legitimately have asked the model to show, not tool
|
||||||
|
// invocations.
|
||||||
|
//
|
||||||
|
// Until the fetch resolves, EXEC_FENCE_RE stays null and exec fences aren't
|
||||||
|
// stripped — normally a sub-second window before the first stream. If the fetch
|
||||||
|
// fails it stays null for the rest of the session (logged below), so live exec
|
||||||
|
// fences won't be stripped until reload. Either way the backend already strips
|
||||||
|
// persisted history (src/tool_parsing.py builds the same regex from TOOL_TAGS),
|
||||||
|
// so a reload always renders clean.
|
||||||
|
let EXEC_FENCE_RE = null;
|
||||||
|
const EXEC_FENCE_NON_TOOL = new Set(['bash', 'python']);
|
||||||
|
|
||||||
|
async function loadExecFenceRegex() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tools', { credentials: 'same-origin' });
|
||||||
|
const data = await res.json();
|
||||||
|
const tags = (data.tools || [])
|
||||||
|
.map((t) => t.id)
|
||||||
|
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
|
||||||
|
if (tags.length) {
|
||||||
|
EXEC_FENCE_RE = new RegExp(
|
||||||
|
'```(?:' + tags.join('|') + ')\\s*\\n[\\s\\S]*?```', 'gi'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Surface the failure rather than swallowing it: EXEC_FENCE_RE stays null,
|
||||||
|
// so this session won't strip live exec fences until reload (persisted path
|
||||||
|
// stays clean regardless).
|
||||||
|
console.warn('chatRenderer: /api/tools fetch failed; live exec-fence stripping disabled until reload', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadExecFenceRegex();
|
||||||
// XML-style tool calls: <minimax:tool_call>, <tool_call>, <function_call>, bare <invoke>
|
// XML-style tool calls: <minimax:tool_call>, <tool_call>, <function_call>, bare <invoke>
|
||||||
const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi;
|
const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi;
|
||||||
const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
|
const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
|
||||||
@@ -852,7 +889,7 @@ export function roleTimestamp(when) {
|
|||||||
*/
|
*/
|
||||||
export function stripToolBlocks(text) {
|
export function stripToolBlocks(text) {
|
||||||
let cleaned = text.replace(TOOL_CALL_RE, '');
|
let cleaned = text.replace(TOOL_CALL_RE, '');
|
||||||
cleaned = cleaned.replace(EXEC_FENCE_RE, '');
|
if (EXEC_FENCE_RE) cleaned = cleaned.replace(EXEC_FENCE_RE, '');
|
||||||
cleaned = cleaned.replace(DSML_TOOL_RE, '');
|
cleaned = cleaned.replace(DSML_TOOL_RE, '');
|
||||||
cleaned = cleaned.replace(DSML_STRAY_RE, '');
|
cleaned = cleaned.replace(DSML_STRAY_RE, '');
|
||||||
cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
|
cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
|
||||||
@@ -1974,6 +2011,142 @@ export function displayMetrics(messageElement, metrics) {
|
|||||||
if (uiModule) uiModule.scrollHistory();
|
if (uiModule) uiModule.scrollHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove any unanswered multiple-choice cards currently in the chat. */
|
||||||
|
export function removeAskUserCards(root) {
|
||||||
|
const scope = root || document.getElementById('chat-history') || document;
|
||||||
|
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an ask_user payload as a durable choice card.
|
||||||
|
*
|
||||||
|
* This lives in the history renderer rather than the streaming loop so the
|
||||||
|
* same UI can be used both for a live SSE event and for a persisted tool event
|
||||||
|
* after a session reload.
|
||||||
|
*/
|
||||||
|
export function renderAskUserCard(payload, options) {
|
||||||
|
const aq = payload || {};
|
||||||
|
const opts = Array.isArray(aq.options) ? aq.options : [];
|
||||||
|
const chatBox = document.getElementById('chat-history');
|
||||||
|
if (!chatBox || !aq.question || opts.length < 2) return null;
|
||||||
|
|
||||||
|
const renderOptions = options || {};
|
||||||
|
removeAskUserCards(chatBox);
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'ask-user-card';
|
||||||
|
card.setAttribute('role', 'group');
|
||||||
|
card.tabIndex = -1;
|
||||||
|
const multi = !!aq.multi;
|
||||||
|
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
||||||
|
|
||||||
|
const head = document.createElement('div');
|
||||||
|
head.className = 'ask-user-head';
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.type = 'button';
|
||||||
|
closeBtn.className = 'modal-close ask-user-close';
|
||||||
|
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||||
|
closeBtn.textContent = '×';
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
card.remove();
|
||||||
|
const input = uiModule.el('message');
|
||||||
|
if (input) input.focus();
|
||||||
|
});
|
||||||
|
head.appendChild(closeBtn);
|
||||||
|
card.appendChild(head);
|
||||||
|
|
||||||
|
const question = document.createElement('div');
|
||||||
|
question.className = 'ask-user-question';
|
||||||
|
question.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||||
|
question.innerHTML = emojiText(aq.question);
|
||||||
|
card.appendChild(question);
|
||||||
|
card.setAttribute('aria-labelledby', question.id);
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'ask-user-options';
|
||||||
|
card.appendChild(list);
|
||||||
|
|
||||||
|
const send = (text) => {
|
||||||
|
if (!text) return;
|
||||||
|
card.remove();
|
||||||
|
const input = uiModule.el('message');
|
||||||
|
if (input) input.value = text;
|
||||||
|
const sendButton = document.querySelector('.send-btn');
|
||||||
|
if (sendButton) sendButton.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
opts.forEach((opt) => {
|
||||||
|
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
|
||||||
|
if (!label) return;
|
||||||
|
const description = (opt && opt.description) ? String(opt.description) : '';
|
||||||
|
const row = document.createElement(multi ? 'label' : 'button');
|
||||||
|
row.className = 'ask-user-option';
|
||||||
|
if (multi) {
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.value = label;
|
||||||
|
row.appendChild(checkbox);
|
||||||
|
}
|
||||||
|
const labelText = document.createElement('span');
|
||||||
|
labelText.className = 'ask-user-option-label';
|
||||||
|
labelText.innerHTML = emojiText(label);
|
||||||
|
row.appendChild(labelText);
|
||||||
|
if (description) {
|
||||||
|
const descriptionText = document.createElement('span');
|
||||||
|
descriptionText.className = 'ask-user-option-desc';
|
||||||
|
descriptionText.innerHTML = emojiText(description);
|
||||||
|
row.appendChild(descriptionText);
|
||||||
|
}
|
||||||
|
if (!multi) {
|
||||||
|
row.type = 'button';
|
||||||
|
row.addEventListener('click', () => send(label));
|
||||||
|
}
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const other = document.createElement('div');
|
||||||
|
other.className = 'ask-user-other';
|
||||||
|
const otherInput = document.createElement('input');
|
||||||
|
otherInput.type = 'text';
|
||||||
|
otherInput.className = 'styled-prompt-input ask-user-other-input';
|
||||||
|
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
|
||||||
|
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
|
||||||
|
const otherSend = document.createElement('button');
|
||||||
|
otherSend.type = 'button';
|
||||||
|
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
|
||||||
|
otherSend.setAttribute('aria-label', 'Send answer');
|
||||||
|
otherSend.textContent = multi ? 'Send selection' : 'Send';
|
||||||
|
const submit = () => {
|
||||||
|
const freeText = otherInput.value.trim();
|
||||||
|
if (multi) {
|
||||||
|
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map((input) => input.value);
|
||||||
|
if (freeText) picked.push(freeText);
|
||||||
|
if (picked.length) send(picked.join(', '));
|
||||||
|
} else if (freeText) {
|
||||||
|
send(freeText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
otherSend.addEventListener('click', submit);
|
||||||
|
otherInput.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
other.appendChild(otherInput);
|
||||||
|
other.appendChild(otherSend);
|
||||||
|
card.appendChild(other);
|
||||||
|
|
||||||
|
chatBox.appendChild(card);
|
||||||
|
if (renderOptions.scroll !== false) {
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
if (renderOptions.focus !== false) {
|
||||||
|
try { card.focus(); } catch (_) {}
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a message to the chat history.
|
* Add a message to the chat history.
|
||||||
*/
|
*/
|
||||||
@@ -1983,6 +2156,11 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
const box = document.getElementById('chat-history');
|
const box = document.getElementById('chat-history');
|
||||||
if (!box) { console.error('Chat history element not found'); return; }
|
if (!box) { console.error('Chat history element not found'); return; }
|
||||||
|
|
||||||
|
// Loading a later user message means any earlier ask_user card was
|
||||||
|
// answered. This also removes the live card as soon as a manual reply is
|
||||||
|
// appended, even when the user did not click one of its buttons.
|
||||||
|
if (role === 'user') removeAskUserCards(box);
|
||||||
|
|
||||||
var esc = uiModule.esc;
|
var esc = uiModule.esc;
|
||||||
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
||||||
|
|
||||||
@@ -1990,6 +2168,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
|
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
|
||||||
const roundTexts = metadata.round_texts || [];
|
const roundTexts = metadata.round_texts || [];
|
||||||
const toolEvents = metadata.tool_events;
|
const toolEvents = metadata.tool_events;
|
||||||
|
let pendingAskUser = null;
|
||||||
let lastWrap = null;
|
let lastWrap = null;
|
||||||
let firstMsgAi = null;
|
let firstMsgAi = null;
|
||||||
let lastMsgAi = null;
|
let lastMsgAi = null;
|
||||||
@@ -2066,6 +2245,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
box.appendChild(threadWrap);
|
box.appendChild(threadWrap);
|
||||||
}
|
}
|
||||||
for (const ev of roundTools) {
|
for (const ev of roundTools) {
|
||||||
|
if (ev.ask_user) pendingAskUser = ev.ask_user;
|
||||||
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
||||||
let outHtml = '';
|
let outHtml = '';
|
||||||
if (ev.output && ev.output.trim()) {
|
if (ev.output && ev.output.trim()) {
|
||||||
@@ -2129,6 +2309,12 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
||||||
}
|
}
|
||||||
if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
|
if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
|
||||||
|
if (pendingAskUser) {
|
||||||
|
// Session history is rendered oldest-to-newest. A later user message
|
||||||
|
// removes this card; if there is none, the pending choice survives a
|
||||||
|
// refresh. Avoid stealing focus while the history is loading.
|
||||||
|
renderAskUserCard(pendingAskUser, { focus: false, scroll: false });
|
||||||
|
}
|
||||||
return lastWrap;
|
return lastWrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2461,6 +2647,8 @@ const chatRenderer = {
|
|||||||
copyMessageText,
|
copyMessageText,
|
||||||
safeToolScreenshotSrc,
|
safeToolScreenshotSrc,
|
||||||
safeDisplayImageSrc,
|
safeDisplayImageSrc,
|
||||||
|
removeAskUserCards,
|
||||||
|
renderAskUserCard,
|
||||||
buildSourcesBox,
|
buildSourcesBox,
|
||||||
buildFindingsBox,
|
buildFindingsBox,
|
||||||
appendReportButton,
|
appendReportButton,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js';
|
|||||||
import themeModule from '../theme.js';
|
import themeModule from '../theme.js';
|
||||||
import presetsModule from '../presets.js';
|
import presetsModule from '../presets.js';
|
||||||
import markdownModule from '../markdown.js';
|
import markdownModule from '../markdown.js';
|
||||||
|
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||||
|
|
||||||
var escapeHtml = uiModule.esc;
|
var escapeHtml = uiModule.esc;
|
||||||
|
|
||||||
@@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _exportMenuEl = null;
|
let _exportMenuEl = null;
|
||||||
|
let _closeExportMenu = () => {};
|
||||||
function _toggleExportMenu(btn) {
|
function _toggleExportMenu(btn) {
|
||||||
if (_exportMenuEl) { _closeExportMenu(); return; }
|
if (_exportMenuEl) { _closeExportMenu(); return; }
|
||||||
const r = btn.getBoundingClientRect();
|
const r = btn.getBoundingClientRect();
|
||||||
@@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) {
|
|||||||
}
|
}
|
||||||
document.body.appendChild(m);
|
document.body.appendChild(m);
|
||||||
_exportMenuEl = m;
|
_exportMenuEl = m;
|
||||||
setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0);
|
_closeExportMenu = bindMenuDismiss(m, () => {
|
||||||
}
|
|
||||||
function _closeExportMenu() {
|
|
||||||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||||||
|
}, (ev) => !m.contains(ev.target));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _exportCopyMarkdown(_btn) {
|
async function _exportCopyMarkdown(_btn) {
|
||||||
|
|||||||
+19
-22
@@ -31,7 +31,7 @@ import {
|
|||||||
} from './cookbook.js';
|
} from './cookbook.js';
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { _loadTasks, _tmuxGracefulKill } from './cookbookRunning.js';
|
import { _loadTasks, _tmuxGracefulKill, _nextAvailablePort, _taskPort } from './cookbookRunning.js';
|
||||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||||
|
|
||||||
// Map a serve-backend code (vllm / sglang / llamacpp) → the package name
|
// Map a serve-backend code (vllm / sglang / llamacpp) → the package name
|
||||||
@@ -1493,36 +1493,34 @@ export function _expandModelRow(row, modelData) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Detect backend and port now — the pre-launch guard below needs them.
|
||||||
|
const _qrBackendDetect = _detectBackend(modelData);
|
||||||
|
const _qrRunBackend = _qrBackendDetect.backend || 'vllm';
|
||||||
|
const _qrPort = _nextAvailablePort();
|
||||||
|
|
||||||
// ─── Pre-launch: stop the model already serving on this host ───────
|
// ─── Pre-launch: stop colliding serves on the same port ───────
|
||||||
// Two servers can't share port 8000. Without this, the new launch
|
// Different ports coexist fine (e.g. vLLM on 8000 + Qwen VL on
|
||||||
// silently collided and the user saw no feedback. We surface the
|
// 8001). Only block when the new model's port genuinely collides
|
||||||
// conflict and offer to kill the running one first as the default
|
// with a running serve. (Issue #4507)
|
||||||
// action (it's almost always what the user wants).
|
|
||||||
try {
|
try {
|
||||||
const _qrHostStr = _envState.remoteHost || '';
|
const _qrHostStr = _envState.remoteHost || '';
|
||||||
const _activeServes = _loadTasks().filter(t =>
|
const _allServes = _loadTasks().filter(t =>
|
||||||
t && t.type === 'serve'
|
t && t.type === 'serve'
|
||||||
&& (t.remoteHost || '') === _qrHostStr
|
&& (t.remoteHost || '') === _qrHostStr
|
||||||
&& (t.status === 'running' || t.status === 'ready' || t._serveReady)
|
&& (t.status === 'running' || t.status === 'ready' || t._serveReady)
|
||||||
);
|
);
|
||||||
if (_activeServes.length) {
|
const _clashing = _allServes.filter(t => _taskPort(t) === _qrPort);
|
||||||
const _names = _activeServes.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
if (_clashing.length) {
|
||||||
|
const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
||||||
const _ok = await window.styledConfirm?.(
|
const _ok = await window.styledConfirm?.(
|
||||||
`${_names.length} model${_names.length === 1 ? '' : 's'} already serving on ${_qrHostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`,
|
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} on port ${_qrPort} (${_names.join(', ')}). Stop it and launch this one?`,
|
||||||
{ confirmText: 'Stop & launch', cancelText: 'Cancel' }
|
{ confirmText: 'Stop & launch', cancelText: 'Cancel' }
|
||||||
);
|
);
|
||||||
if (!_ok) return;
|
if (!_ok) return;
|
||||||
// Mark + kill each running serve, then wait briefly for the
|
|
||||||
// tmux session to actually go down before we kick off the new
|
|
||||||
// launch. Otherwise vLLM still races against the dying socket.
|
|
||||||
quickRunBtn.disabled = true;
|
quickRunBtn.disabled = true;
|
||||||
quickRunBtn.textContent = 'Stopping…';
|
quickRunBtn.textContent = 'Stopping…';
|
||||||
for (const t of _activeServes) {
|
for (const t of _clashing) {
|
||||||
try {
|
try {
|
||||||
// Use that task's own Stop button if it's rendered (handles
|
|
||||||
// endpoint cleanup, Ollama unload, fade-out). Falls back to
|
|
||||||
// a direct tmux kill if the Active tab isn't in the DOM yet.
|
|
||||||
const _taskEl = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
const _taskEl = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||||
const _stopBtn = _taskEl?.querySelector('.cookbook-task-action-stop');
|
const _stopBtn = _taskEl?.querySelector('.cookbook-task-action-stop');
|
||||||
if (_stopBtn) {
|
if (_stopBtn) {
|
||||||
@@ -1537,11 +1535,12 @@ export function _expandModelRow(row, modelData) {
|
|||||||
}
|
}
|
||||||
} catch (_killErr) { /* best-effort */ }
|
} catch (_killErr) { /* best-effort */ }
|
||||||
}
|
}
|
||||||
// Give the OS a beat to release port 8000.
|
|
||||||
await new Promise(r => setTimeout(r, 2500));
|
await new Promise(r => setTimeout(r, 2500));
|
||||||
}
|
}
|
||||||
} catch (_e) { /* best-effort */ }
|
} catch (_e) { /* best-effort */ }
|
||||||
|
|
||||||
|
// -- Launch ───────────────────────────────────────────────────
|
||||||
|
|
||||||
// ─── Pre-launch driver check ─────────────────────────────────────
|
// ─── Pre-launch driver check ─────────────────────────────────────
|
||||||
// vLLM/SGLang need a working CUDA/ROCm driver. nvidia-smi failures
|
// vLLM/SGLang need a working CUDA/ROCm driver. nvidia-smi failures
|
||||||
// surface as system.gpu_error from our hardware probe; "no GPU
|
// surface as system.gpu_error from our hardware probe; "no GPU
|
||||||
@@ -1550,8 +1549,6 @@ export function _expandModelRow(row, modelData) {
|
|||||||
// user watches `pip install vllm` finish, then sees a cryptic CUDA
|
// user watches `pip install vllm` finish, then sees a cryptic CUDA
|
||||||
// error 10 minutes later. (llama.cpp / Ollama have CPU fallbacks
|
// error 10 minutes later. (llama.cpp / Ollama have CPU fallbacks
|
||||||
// so they skip this gate.)
|
// so they skip this gate.)
|
||||||
const _qrBackendDetect = _detectBackend(modelData);
|
|
||||||
const _qrRunBackend = _qrBackendDetect.backend || 'vllm';
|
|
||||||
if (_qrRunBackend === 'vllm' || _qrRunBackend === 'sglang') {
|
if (_qrRunBackend === 'vllm' || _qrRunBackend === 'sglang') {
|
||||||
const _sys = _hwfitCache?.system || {};
|
const _sys = _hwfitCache?.system || {};
|
||||||
if (_sys.gpu_error) {
|
if (_sys.gpu_error) {
|
||||||
@@ -1658,7 +1655,7 @@ export function _expandModelRow(row, modelData) {
|
|||||||
|
|
||||||
const host = _envState.remoteHost || '';
|
const host = _envState.remoteHost || '';
|
||||||
const hostIp = host.includes('@') ? host.split('@').pop() : host;
|
const hostIp = host.includes('@') ? host.split('@').pop() : host;
|
||||||
const port = '8000';
|
const port = _qrPort;
|
||||||
const detected = _detectBackend(modelData);
|
const detected = _detectBackend(modelData);
|
||||||
const runBackend = detected.backend || 'vllm';
|
const runBackend = detected.backend || 'vllm';
|
||||||
|
|
||||||
@@ -1673,7 +1670,7 @@ export function _expandModelRow(row, modelData) {
|
|||||||
} else if (runBackend === 'llamacpp') {
|
} else if (runBackend === 'llamacpp') {
|
||||||
const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`;
|
const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`;
|
||||||
const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
|
const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
|
||||||
cmd = `llama-server --model "${ggufPath}" --host 0.0.0.0 --port 8080 -ngl 99 -c ${maxCtx} --flash-attn auto`;
|
cmd = `llama-server --model "${ggufPath}" --host 0.0.0.0 --port ${port} -ngl 99 -c ${maxCtx} --flash-attn auto`;
|
||||||
} else {
|
} else {
|
||||||
cmd = `vllm serve ${modelData.name} --host 0.0.0.0 --port ${port}`;
|
cmd = `vllm serve ${modelData.name} --host 0.0.0.0 --port ${port}`;
|
||||||
cmd += ` --tensor-parallel-size ${tp}`;
|
cmd += ` --tensor-parallel-size ${tp}`;
|
||||||
|
|||||||
+9
-11
@@ -33,6 +33,9 @@ import {
|
|||||||
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
|
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
|
||||||
} from './cookbookServe.js';
|
} from './cookbookServe.js';
|
||||||
|
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
import { topPortalZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
const STORAGE_KEY = 'cookbook-presets';
|
const STORAGE_KEY = 'cookbook-presets';
|
||||||
const LAST_STATE_KEY = 'cookbook-last-state';
|
const LAST_STATE_KEY = 'cookbook-last-state';
|
||||||
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||||
@@ -1514,7 +1517,7 @@ async function _fetchDependencies() {
|
|||||||
|
|
||||||
// Wire the installed-package menu.
|
// Wire the installed-package menu.
|
||||||
function _showDepMenu(anchor) {
|
function _showDepMenu(anchor) {
|
||||||
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
|
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
|
||||||
const row = anchor.closest('.cookbook-dep-row');
|
const row = anchor.closest('.cookbook-dep-row');
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
const pipName = row.dataset.depPip;
|
const pipName = row.dataset.depPip;
|
||||||
@@ -1527,7 +1530,7 @@ async function _fetchDependencies() {
|
|||||||
const minW = 150;
|
const minW = 150;
|
||||||
let left = Math.min(rect.right - minW, window.innerWidth - minW - 8);
|
let left = Math.min(rect.right - minW, window.innerWidth - minW - 8);
|
||||||
left = Math.max(8, left);
|
left = Math.max(8, left);
|
||||||
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||||
const upIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>';
|
const upIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>';
|
||||||
const it = document.createElement('div');
|
const it = document.createElement('div');
|
||||||
it.className = 'dropdown-item-compact';
|
it.className = 'dropdown-item-compact';
|
||||||
@@ -1535,7 +1538,7 @@ async function _fetchDependencies() {
|
|||||||
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
|
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
|
||||||
it.addEventListener('click', async (e) => {
|
it.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.remove();
|
close();
|
||||||
await _installDep(pipName, pkgName, isLocalOnly, true, null);
|
await _installDep(pipName, pkgName, isLocalOnly, true, null);
|
||||||
});
|
});
|
||||||
dropdown.appendChild(it);
|
dropdown.appendChild(it);
|
||||||
@@ -1563,19 +1566,14 @@ async function _fetchDependencies() {
|
|||||||
dropdown.appendChild(source);
|
dropdown.appendChild(source);
|
||||||
}
|
}
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) =>
|
||||||
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) {
|
!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
|
||||||
dropdown.remove();
|
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
}
|
||||||
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
|
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (document.querySelector('.cookbook-dep-menu')) {
|
if (document.querySelector('.cookbook-dep-menu')) {
|
||||||
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
|
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_showDepMenu(btn);
|
_showDepMenu(btn);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Pure port helpers extracted so they're unit-testable without the
|
||||||
|
// browser-bound rest of cookbookRunning.js (issue #4507 follow-up).
|
||||||
|
|
||||||
|
// Read the port out of a serve launch command. Handles --port 8000,
|
||||||
|
// --port=8000, -p 8000, and -p=8000. Returns '' when none is present.
|
||||||
|
export function portOf(cmd) {
|
||||||
|
const s = cmd || '';
|
||||||
|
const m = s.match(/--port[=\s]+(\d+)/) || s.match(/(?:^|\s)-p[=\s]+(\d+)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lowest free port >= start that isn't in usedPorts (array or Set of
|
||||||
|
// numbers/strings). Returns a string to match the serve command format.
|
||||||
|
export function nextFreePort(usedPorts, start = 8000) {
|
||||||
|
const used = new Set([...usedPorts].map(p => parseInt(p, 10)));
|
||||||
|
let port = start;
|
||||||
|
while (used.has(port)) port++;
|
||||||
|
return String(port);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import uiModule from './ui.js';
|
|||||||
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
|
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
|
||||||
import { registerMenuDismiss } from './escMenuStack.js';
|
import { registerMenuDismiss } from './escMenuStack.js';
|
||||||
import { computeProgressSignal } from './cookbookProgressSignal.js';
|
import { computeProgressSignal } from './cookbookProgressSignal.js';
|
||||||
|
import { portOf, nextFreePort } from './cookbookPorts.js';
|
||||||
|
|
||||||
// Human-friendly badge label for a task's internal status. Avoids surfacing
|
// Human-friendly badge label for a task's internal status. Avoids surfacing
|
||||||
// the word "error" in the sidebar — a server the user stopped or one that
|
// the word "error" in the sidebar — a server the user stopped or one that
|
||||||
@@ -266,9 +267,7 @@ function _taskHostLabel(task) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _taskPort(task) {
|
function _taskPort(task) {
|
||||||
const cmd = task?.payload?._cmd || '';
|
return portOf(task?.payload?._cmd || '');
|
||||||
const match = cmd.match(/--port\s+(\d+)/);
|
|
||||||
return match ? match[1] : '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _buildCrashReport(task, outputText) {
|
function _buildCrashReport(task, outputText) {
|
||||||
@@ -455,16 +454,14 @@ function _nextAvailablePort() {
|
|||||||
const usedPorts = new Set();
|
const usedPorts = new Set();
|
||||||
tasks.forEach(t => {
|
tasks.forEach(t => {
|
||||||
if (t.type === 'serve' && (t.status === 'running' || t.status === 'queued')) {
|
if (t.type === 'serve' && (t.status === 'running' || t.status === 'queued')) {
|
||||||
const m = t.payload?._cmd?.match(/--port\s+(\d+)/);
|
const p = _taskPort(t);
|
||||||
if (m) usedPorts.add(parseInt(m[1]));
|
if (p) usedPorts.add(parseInt(p));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
presets.forEach(p => {
|
presets.forEach(p => {
|
||||||
if (p.port) usedPorts.add(parseInt(p.port));
|
if (p.port) usedPorts.add(parseInt(p.port));
|
||||||
});
|
});
|
||||||
let port = 8000;
|
return nextFreePort(usedPorts);
|
||||||
while (usedPorts.has(port)) port++;
|
|
||||||
return String(port);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Endpoint cleanup ──
|
// ── Endpoint cleanup ──
|
||||||
@@ -3987,4 +3984,4 @@ export function initRunning(shared) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Also export _retryDownload and _nextAvailablePort for use by other modules
|
// Also export _retryDownload and _nextAvailablePort for use by other modules
|
||||||
export { _retryDownload, _nextAvailablePort, _processQueue };
|
export { _retryDownload, _nextAvailablePort, _processQueue, _taskPort };
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { modelColor } from './chatRenderer.js';
|
|||||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||||
import { _hwfitCache } from './cookbook-hwfit.js';
|
import { _hwfitCache } from './cookbook-hwfit.js';
|
||||||
|
import { topPortalZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
// Shared state/functions injected by init()
|
// Shared state/functions injected by init()
|
||||||
let _envState;
|
let _envState;
|
||||||
@@ -1019,7 +1020,7 @@ function _rerenderCachedModels() {
|
|||||||
cancelDiv.addEventListener('click', () => { closeDropdown(); });
|
cancelDiv.addEventListener('click', () => { closeDropdown(); });
|
||||||
dropdown.appendChild(cancelDiv);
|
dropdown.appendChild(cancelDiv);
|
||||||
const rect = btn.getBoundingClientRect();
|
const rect = btn.getBoundingClientRect();
|
||||||
dropdown.style.cssText = `position:fixed;z-index:10001;visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
|
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
// Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ
|
// Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ
|
||||||
// on mobile under the dynamic toolbar). Flip above the button if there's no
|
// on mobile under the dynamic toolbar). Flip above the button if there's no
|
||||||
@@ -2166,7 +2167,7 @@ function _rerenderCachedModels() {
|
|||||||
// Cap width/height to the viewport and start hidden — we clamp the final
|
// Cap width/height to the viewport and start hidden — we clamp the final
|
||||||
// position after mount (below) using the menu's real measured size, so it
|
// position after mount (below) using the menu's real measured size, so it
|
||||||
// can't run off-screen on a narrow mobile viewport.
|
// can't run off-screen on a narrow mobile viewport.
|
||||||
dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:10001;top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:${topPortalZ()};top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||||
|
|
||||||
if (!modelSlots.length) {
|
if (!modelSlots.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
@@ -2957,17 +2958,25 @@ function _rerenderCachedModels() {
|
|||||||
&& ((t.remoteHost || '') === _hostStr || (t.remoteServerKey || '') === _serverKeyStr)
|
&& ((t.remoteHost || '') === _hostStr || (t.remoteServerKey || '') === _serverKeyStr)
|
||||||
&& (t.status === 'running' || t.status === 'ready' || t._serveReady)
|
&& (t.status === 'running' || t.status === 'ready' || t._serveReady)
|
||||||
);
|
);
|
||||||
|
// Only block when the new model's port genuinely collides with
|
||||||
|
// a running serve. Different ports coexist fine (issue #4507).
|
||||||
if (_active.length) {
|
if (_active.length) {
|
||||||
const _names = _active.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
const _newPort = (launchCmd.match(/--port[=\s]+(\d+)/) || [])[1] || '';
|
||||||
|
const _clashing = _newPort
|
||||||
|
? _active.filter(t => _runningMod._taskPort(t) === _newPort)
|
||||||
|
: _active;
|
||||||
|
if (_clashing.length) {
|
||||||
|
const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
||||||
|
const _portNote = _newPort ? ` on port ${_newPort}` : '';
|
||||||
const _ok = await window.styledConfirm(
|
const _ok = await window.styledConfirm(
|
||||||
`${_active.length} model${_active.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`,
|
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')})${_portNote}. Stop it and launch this one?`,
|
||||||
{ title: 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' },
|
{ title: _newPort ? `Port ${_newPort} in use` : 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' },
|
||||||
);
|
);
|
||||||
if (!_ok) { _restoreLaunchBtn(); return; }
|
if (!_ok) { _restoreLaunchBtn(); return; }
|
||||||
// Kill each active serve; prefer the rendered Stop button so
|
// Kill each clashing serve; prefer the rendered Stop button so
|
||||||
// endpoint cleanup + Ollama unload run normally. Fall back to
|
// endpoint cleanup + Ollama unload run normally. Fall back to
|
||||||
// a raw tmux kill when the Active tab isn't in the DOM.
|
// a raw tmux kill when the Active tab isn't in the DOM.
|
||||||
for (const t of _active) {
|
for (const t of _clashing) {
|
||||||
try {
|
try {
|
||||||
const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||||
const _btn = _el?.querySelector('.cookbook-task-action-stop');
|
const _btn = _el?.querySelector('.cookbook-task-action-stop');
|
||||||
@@ -2982,9 +2991,9 @@ function _rerenderCachedModels() {
|
|||||||
}
|
}
|
||||||
} catch (_killErr) { /* best-effort */ }
|
} catch (_killErr) { /* best-effort */ }
|
||||||
}
|
}
|
||||||
// Give the OS a beat to release port 8000.
|
|
||||||
await new Promise(r => setTimeout(r, 2500));
|
await new Promise(r => setTimeout(r, 2500));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (_e) { /* best-effort */ }
|
} catch (_e) { /* best-effort */ }
|
||||||
|
|
||||||
const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState);
|
const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState);
|
||||||
|
|||||||
+22
-38
@@ -16,6 +16,7 @@ import spinnerModule from './spinner.js';
|
|||||||
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
|
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
|
||||||
import signatureModule from './signature.js';
|
import signatureModule from './signature.js';
|
||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
let API_BASE = '';
|
let API_BASE = '';
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
@@ -666,7 +667,7 @@ import * as Modals from './modalManager.js';
|
|||||||
overlay.className = 'modal pdf-export-overlay';
|
overlay.className = 'modal pdf-export-overlay';
|
||||||
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);';
|
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);';
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="modal-content" style="width:min(780px,94vw);max-height:86vh;">
|
<div class="modal-content" style="width:min(780px,94vw);">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4>Export filled PDF</h4>
|
<h4>Export filled PDF</h4>
|
||||||
<button id="pdf-export-close" class="modal-close" title="Close">×</button>
|
<button id="pdf-export-close" class="modal-close" title="Close">×</button>
|
||||||
@@ -3331,7 +3332,10 @@ import * as Modals from './modalManager.js';
|
|||||||
let _docAiReplyChoiceMenu = null;
|
let _docAiReplyChoiceMenu = null;
|
||||||
function _closeDocAiReplyChoice() {
|
function _closeDocAiReplyChoice() {
|
||||||
if (_docAiReplyChoiceMenu) {
|
if (_docAiReplyChoiceMenu) {
|
||||||
try { _docAiReplyChoiceMenu.remove(); } catch (_) {}
|
// Tear down through the menu's registered dismiss (drops its outside-click
|
||||||
|
// listener + Escape-stack entry) rather than orphaning them with a raw
|
||||||
|
// remove(); the onClose below nulls the ref.
|
||||||
|
try { dismissOrRemove(_docAiReplyChoiceMenu); } catch (_) {}
|
||||||
_docAiReplyChoiceMenu = null;
|
_docAiReplyChoiceMenu = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3382,6 +3386,14 @@ import * as Modals from './modalManager.js';
|
|||||||
const noteInput = menu.querySelector('[data-note-input]');
|
const noteInput = menu.querySelector('[data-note-input]');
|
||||||
setTimeout(() => noteInput?.focus(), 0);
|
setTimeout(() => noteInput?.focus(), 0);
|
||||||
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
|
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
_docAiReplyChoiceMenu = menu;
|
||||||
|
// Outside-click AND Escape both route through the central esc-stack via
|
||||||
|
// bindMenuDismiss; onClose owns the actual teardown (node removal + state).
|
||||||
|
const close = bindMenuDismiss(menu, () => {
|
||||||
|
try { menu.remove(); } catch (_) {}
|
||||||
|
if (_docAiReplyChoiceMenu === menu) _docAiReplyChoiceMenu = null;
|
||||||
|
});
|
||||||
menu.addEventListener('click', async (ev) => {
|
menu.addEventListener('click', async (ev) => {
|
||||||
const choice = ev.target.closest('[data-mode]');
|
const choice = ev.target.closest('[data-mode]');
|
||||||
if (!choice) return;
|
if (!choice) return;
|
||||||
@@ -3389,26 +3401,9 @@ import * as Modals from './modalManager.js';
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
|
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
|
||||||
const noteHint = (noteInput?.value || '').trim();
|
const noteHint = (noteInput?.value || '').trim();
|
||||||
_closeDocAiReplyChoice();
|
close();
|
||||||
await _aiReply({ mode, noteHint });
|
await _aiReply({ mode, noteHint });
|
||||||
});
|
});
|
||||||
document.body.appendChild(menu);
|
|
||||||
_docAiReplyChoiceMenu = menu;
|
|
||||||
const outsideClose = (ev) => {
|
|
||||||
if (menu.contains(ev.target)) return;
|
|
||||||
document.removeEventListener('click', outsideClose, true);
|
|
||||||
_closeDocAiReplyChoice();
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
|
|
||||||
// Esc to close.
|
|
||||||
const escClose = (ev) => {
|
|
||||||
if (ev.key === 'Escape') {
|
|
||||||
ev.stopPropagation();
|
|
||||||
document.removeEventListener('keydown', escClose, true);
|
|
||||||
_closeDocAiReplyChoice();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', escClose, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _aiReply(opts = {}) {
|
async function _aiReply(opts = {}) {
|
||||||
@@ -8591,9 +8586,10 @@ import * as Modals from './modalManager.js';
|
|||||||
|
|
||||||
function showExportMenu(e, anchorRect) {
|
function showExportMenu(e, anchorRect) {
|
||||||
if (e) e.stopPropagation();
|
if (e) e.stopPropagation();
|
||||||
// Remove existing menu if any
|
// Remove existing menu if any (toggle off) — tear it down through its
|
||||||
|
// registered dismiss so the outside-click listener + Escape-stack entry go.
|
||||||
const existing = document.getElementById('doc-export-menu');
|
const existing = document.getElementById('doc-export-menu');
|
||||||
if (existing) { existing.remove(); return; }
|
if (existing) { dismissOrRemove(existing); return; }
|
||||||
|
|
||||||
// Position from provided rect, clicked element, or fallback to language select
|
// Position from provided rect, clicked element, or fallback to language select
|
||||||
const rect = anchorRect
|
const rect = anchorRect
|
||||||
@@ -8643,7 +8639,7 @@ import * as Modals from './modalManager.js';
|
|||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'doc-overflow-item';
|
item.className = 'doc-overflow-item';
|
||||||
item.textContent = opt.label;
|
item.textContent = opt.label;
|
||||||
item.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); opt.fn(); });
|
item.addEventListener('click', (ev) => { ev.stopPropagation(); close(); opt.fn(); });
|
||||||
menu.appendChild(item);
|
menu.appendChild(item);
|
||||||
if (opt._divider) {
|
if (opt._divider) {
|
||||||
const sep = document.createElement('div');
|
const sep = document.createElement('div');
|
||||||
@@ -8661,21 +8657,9 @@ import * as Modals from './modalManager.js';
|
|||||||
menu.style.top = 'auto';
|
menu.style.top = 'auto';
|
||||||
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
|
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
|
||||||
}
|
}
|
||||||
const close = (ev) => {
|
// Outside-click AND Escape both route through the central esc-stack via
|
||||||
if (ev && ev.type === 'keydown') {
|
// bindMenuDismiss; onClose owns the actual node removal.
|
||||||
if (ev.key !== 'Escape') return;
|
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
ev.stopImmediatePropagation?.();
|
|
||||||
} else if (ev && menu.contains(ev.target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
menu.remove();
|
|
||||||
document.removeEventListener('click', close);
|
|
||||||
document.removeEventListener('keydown', close, true);
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close), 100);
|
|
||||||
document.addEventListener('keydown', close, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportAsHtml() {
|
function exportAsHtml() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Extracted from document.js to reduce file size.
|
* Extracted from document.js to reduce file size.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { topPortalZ } from './toolWindowZOrder.js';
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import sessionModule from './sessions.js';
|
import sessionModule from './sessions.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
@@ -227,7 +228,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
dd.style.right = (window.innerWidth - rect.right) + 'px';
|
dd.style.right = (window.innerWidth - rect.right) + 'px';
|
||||||
dd.style.top = (rect.bottom + 2) + 'px';
|
dd.style.top = (rect.bottom + 2) + 'px';
|
||||||
dd.style.display = 'block';
|
dd.style.display = 'block';
|
||||||
dd.style.zIndex = '100000';
|
dd.style.zIndex = String(topPortalZ());
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const mr = dd.getBoundingClientRect();
|
const mr = dd.getBoundingClientRect();
|
||||||
if (mr.bottom > window.innerHeight - 8) {
|
if (mr.bottom > window.innerHeight - 8) {
|
||||||
@@ -629,7 +630,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
const rect = menuBtn.getBoundingClientRect();
|
const rect = menuBtn.getBoundingClientRect();
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
dropdown.dataset.owner = doc.id;
|
dropdown.dataset.owner = doc.id;
|
||||||
dropdown.style.cssText = 'position:fixed;z-index:10000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;';
|
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;`;
|
||||||
dropdown.style.top = (rect.bottom + 4) + 'px';
|
dropdown.style.top = (rect.bottom + 4) + 'px';
|
||||||
dropdown.style.left = 'auto';
|
dropdown.style.left = 'auto';
|
||||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
||||||
@@ -1595,7 +1596,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
modal.className = 'modal';
|
modal.className = 'modal';
|
||||||
modal.id = 'doclib-modal';
|
modal.id = 'doclib-modal';
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);">
|
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);background:var(--bg);">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<!-- Header title + icon mirror the currently-active sub-tab (Chats /
|
<!-- Header title + icon mirror the currently-active sub-tab (Chats /
|
||||||
Documents / Research / Archive) so the user sees ONE icon at
|
Documents / Research / Archive) so the user sees ONE icon at
|
||||||
|
|||||||
+6
-11
@@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
|
|||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
import { applyEdgeDock } from './modalSnap.js';
|
import { applyEdgeDock } from './modalSnap.js';
|
||||||
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
|
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
const _acct = () => window.__odysseusActiveEmailAccount
|
const _acct = () => window.__odysseusActiveEmailAccount
|
||||||
@@ -915,7 +916,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _showEmailMenu(em, anchor, itemEl) {
|
function _showEmailMenu(em, anchor, itemEl) {
|
||||||
document.querySelectorAll('.email-dropdown').forEach(d => d.remove());
|
document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
|
||||||
|
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'dropdown email-dropdown show';
|
dropdown.className = 'dropdown email-dropdown show';
|
||||||
@@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) {
|
|||||||
_showRemindSubmenu(em, dropdown);
|
_showRemindSubmenu(em, dropdown);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dropdown.remove();
|
close();
|
||||||
a.action();
|
a.action();
|
||||||
});
|
});
|
||||||
dropdown.appendChild(menuItem);
|
dropdown.appendChild(menuItem);
|
||||||
@@ -946,13 +947,7 @@ function _showEmailMenu(em, anchor, itemEl) {
|
|||||||
|
|
||||||
anchor.appendChild(dropdown);
|
anchor.appendChild(dropdown);
|
||||||
|
|
||||||
const close = (e) => {
|
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
|
||||||
if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
|
|
||||||
dropdown.remove();
|
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Reminder submenu (creates a Note with a reminder for this email) ----
|
// ---- Reminder submenu (creates a Note with a reminder for this email) ----
|
||||||
@@ -987,7 +982,7 @@ function _showRemindSubmenu(em, parentDropdown) {
|
|||||||
item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
|
item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
|
||||||
item.addEventListener('click', async (e) => {
|
item.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
parentDropdown.remove();
|
dismissOrRemove(parentDropdown);
|
||||||
await _createReplyReminder(em, p.date);
|
await _createReplyReminder(em, p.date);
|
||||||
});
|
});
|
||||||
parentDropdown.appendChild(item);
|
parentDropdown.appendChild(item);
|
||||||
@@ -997,7 +992,7 @@ function _showRemindSubmenu(em, parentDropdown) {
|
|||||||
customItem.innerHTML = '<span>Pick date and time…</span>';
|
customItem.innerHTML = '<span>Pick date and time…</span>';
|
||||||
customItem.addEventListener('click', async (e) => {
|
customItem.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
parentDropdown.remove();
|
dismissOrRemove(parentDropdown);
|
||||||
const tmp = document.createElement('input');
|
const tmp = document.createElement('input');
|
||||||
tmp.type = 'datetime-local';
|
tmp.type = 'datetime-local';
|
||||||
const def = new Date(tomorrow);
|
const def = new Date(tomorrow);
|
||||||
|
|||||||
+27
-45
@@ -8,6 +8,7 @@ import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
|
|||||||
import { folderDisplayName, sortedFolders } from './emailInbox.js';
|
import { folderDisplayName, sortedFolders } from './emailInbox.js';
|
||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
|
import { topPortalZ } from './toolWindowZOrder.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
import {
|
import {
|
||||||
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
} from './emailLibrary/signatureFold.js';
|
} from './emailLibrary/signatureFold.js';
|
||||||
import { state } from './emailLibrary/state.js';
|
import { state } from './emailLibrary/state.js';
|
||||||
import { collapseSidebarToRail } from './modalSnap.js';
|
import { collapseSidebarToRail } from './modalSnap.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _emailUnreadChipClickWired = false;
|
let _emailUnreadChipClickWired = false;
|
||||||
@@ -858,7 +860,7 @@ export function openEmailLibrary(opts = {}) {
|
|||||||
modal.className = 'modal';
|
modal.className = 'modal';
|
||||||
modal.id = 'email-lib-modal';
|
modal.id = 'email-lib-modal';
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);max-height:85vh;background:var(--bg);">
|
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);background:var(--bg);">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4>
|
<h4>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;">
|
||||||
@@ -4866,7 +4868,7 @@ async function _openEmailAsTab(em, folder) {
|
|||||||
modal.className = 'modal email-reader-tab-modal';
|
modal.className = 'modal email-reader-tab-modal';
|
||||||
modal.id = modalId;
|
modal.id = modalId;
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);max-height:85vh;display:flex;flex-direction:column;">
|
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);display:flex;flex-direction:column;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
|
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
|
||||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span>
|
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span>
|
||||||
@@ -5101,7 +5103,7 @@ async function _openEmailWindow(em, folder) {
|
|||||||
modal.id = winId;
|
modal.id = winId;
|
||||||
modal.style.cssText = 'pointer-events:none;background:transparent;';
|
modal.style.cssText = 'pointer-events:none;background:transparent;';
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-content email-window-content" style="width:min(640px, 92vw);max-height:80vh;display:flex;flex-direction:column;background:var(--bg);">
|
<div class="modal-content email-window-content" style="width:min(640px, 92vw);display:flex;flex-direction:column;background:var(--bg);">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
|
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||||
@@ -5499,23 +5501,19 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
|||||||
// Toggle: if a dropdown for THIS anchor is already open, close it.
|
// Toggle: if a dropdown for THIS anchor is already open, close it.
|
||||||
const existing = document.querySelector('.email-card-dropdown');
|
const existing = document.querySelector('.email-card-dropdown');
|
||||||
if (existing && existing._anchor === anchor) {
|
if (existing && existing._anchor === anchor) {
|
||||||
existing.remove();
|
dismissOrRemove(existing);
|
||||||
anchor.classList.remove('reader-more-active');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Otherwise close any other open dropdown (and clear its anchor's active
|
// Otherwise close any other open dropdown (its own teardown clears its
|
||||||
// state) before opening a fresh one.
|
// anchor's active state) before opening a fresh one.
|
||||||
document.querySelectorAll('.email-card-dropdown').forEach(d => {
|
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||||
if (d._anchor) d._anchor.classList.remove('reader-more-active');
|
|
||||||
d.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'email-card-dropdown';
|
dropdown.className = 'email-card-dropdown';
|
||||||
dropdown._anchor = anchor;
|
dropdown._anchor = anchor;
|
||||||
anchor.classList.add('reader-more-active');
|
anchor.classList.add('reader-more-active');
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||||
|
|
||||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||||
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||||
@@ -5721,8 +5719,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
|||||||
_showLibRemindSubmenu(em, dropdown);
|
_showLibRemindSubmenu(em, dropdown);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dropdown.remove();
|
close();
|
||||||
anchor.classList.remove('reader-more-active');
|
|
||||||
a.action();
|
a.action();
|
||||||
});
|
});
|
||||||
dropdown.appendChild(item);
|
dropdown.appendChild(item);
|
||||||
@@ -5735,30 +5732,25 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
|||||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||||
cancelItem.addEventListener('click', (e) => {
|
cancelItem.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.remove();
|
close();
|
||||||
anchor.classList.remove('reader-more-active');
|
|
||||||
});
|
});
|
||||||
dropdown.appendChild(cancelItem);
|
dropdown.appendChild(cancelItem);
|
||||||
|
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
_fitEmailDropdown(dropdown, rect);
|
_fitEmailDropdown(dropdown, rect);
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(dropdown, () => {
|
||||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
|
||||||
dropdown.remove();
|
dropdown.remove();
|
||||||
anchor.classList.remove('reader-more-active');
|
anchor.classList.remove('reader-more-active');
|
||||||
document.removeEventListener('click', close, true);
|
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showCardMenu(em, anchor) {
|
function _showCardMenu(em, anchor) {
|
||||||
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
|
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||||
|
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'email-card-dropdown';
|
dropdown.className = 'email-card-dropdown';
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||||
|
|
||||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||||
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
|
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
|
||||||
@@ -5918,8 +5910,7 @@ function _showCardMenu(em, anchor) {
|
|||||||
_showLibRemindSubmenu(em, dropdown);
|
_showLibRemindSubmenu(em, dropdown);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dropdown.remove();
|
close();
|
||||||
anchor.classList.remove('reader-more-active');
|
|
||||||
a.action();
|
a.action();
|
||||||
});
|
});
|
||||||
dropdown.appendChild(item);
|
dropdown.appendChild(item);
|
||||||
@@ -5932,30 +5923,25 @@ function _showCardMenu(em, anchor) {
|
|||||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||||
cancelItem.addEventListener('click', (e) => {
|
cancelItem.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.remove();
|
close();
|
||||||
anchor.classList.remove('reader-more-active');
|
|
||||||
});
|
});
|
||||||
dropdown.appendChild(cancelItem);
|
dropdown.appendChild(cancelItem);
|
||||||
|
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
_fitEmailDropdown(dropdown, rect);
|
_fitEmailDropdown(dropdown, rect);
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(dropdown, () => {
|
||||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
|
||||||
dropdown.remove();
|
dropdown.remove();
|
||||||
anchor.classList.remove('reader-more-active');
|
anchor.classList.remove('reader-more-active');
|
||||||
document.removeEventListener('click', close, true);
|
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
|
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
|
||||||
function _showBulkActionsMenu(anchor) {
|
function _showBulkActionsMenu(anchor) {
|
||||||
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
|
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'email-card-dropdown email-bulk-menu';
|
dropdown.className = 'email-card-dropdown email-bulk-menu';
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
||||||
const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
|
const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
|
||||||
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||||
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||||
@@ -5968,7 +5954,7 @@ function _showBulkActionsMenu(anchor) {
|
|||||||
const it = document.createElement('div');
|
const it = document.createElement('div');
|
||||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||||
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
|
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
|
||||||
dropdown.appendChild(it);
|
dropdown.appendChild(it);
|
||||||
}
|
}
|
||||||
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
|
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
|
||||||
@@ -5978,7 +5964,7 @@ function _showBulkActionsMenu(anchor) {
|
|||||||
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
|
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
|
||||||
cancelIt.addEventListener('click', (e) => {
|
cancelIt.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.remove();
|
close();
|
||||||
// Cancel inside the bulk-Actions menu also exits select mode — matches the
|
// Cancel inside the bulk-Actions menu also exits select mode — matches the
|
||||||
// documents bulk dropdown.
|
// documents bulk dropdown.
|
||||||
state._selectMode = false;
|
state._selectMode = false;
|
||||||
@@ -5989,13 +5975,9 @@ function _showBulkActionsMenu(anchor) {
|
|||||||
dropdown.appendChild(cancelIt);
|
dropdown.appendChild(cancelIt);
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
_fitEmailDropdown(dropdown, rect);
|
_fitEmailDropdown(dropdown, rect);
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(dropdown, () => {
|
||||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
|
||||||
dropdown.remove();
|
dropdown.remove();
|
||||||
document.removeEventListener('click', close, true);
|
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _updateBulkBar() {
|
function _updateBulkBar() {
|
||||||
@@ -6240,7 +6222,7 @@ function _showAiReplyChoice(btn, em, data) {
|
|||||||
`max-height:${window.innerHeight - 16}px`,
|
`max-height:${window.innerHeight - 16}px`,
|
||||||
'overflow:auto',
|
'overflow:auto',
|
||||||
'box-sizing:border-box',
|
'box-sizing:border-box',
|
||||||
'z-index:10060',
|
`z-index:${topPortalZ()}`,
|
||||||
'display:flex',
|
'display:flex',
|
||||||
'gap:6px',
|
'gap:6px',
|
||||||
'padding:6px',
|
'padding:6px',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
* faces (😂, 👍, 😎) have no text form and are intentionally excluded.
|
* faces (😂, 👍, 😎) have no text form and are intentionally excluded.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { topPortalZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
// Each entry: [char, label, svgPath OR svg]
|
// Each entry: [char, label, svgPath OR svg]
|
||||||
// SVG icons matching Lucide style (24x24 viewBox, 2 stroke)
|
// SVG icons matching Lucide style (24x24 viewBox, 2 stroke)
|
||||||
const I = (path) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
|
const I = (path) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
|
||||||
@@ -158,7 +160,7 @@ function togglePicker(anchor, target) {
|
|||||||
_pickerEl.style.position = 'fixed';
|
_pickerEl.style.position = 'fixed';
|
||||||
_pickerEl.style.top = (rect.bottom + 4) + 'px';
|
_pickerEl.style.top = (rect.bottom + 4) + 'px';
|
||||||
_pickerEl.style.left = rect.left + 'px';
|
_pickerEl.style.left = rect.left + 'px';
|
||||||
_pickerEl.style.zIndex = '10000';
|
_pickerEl.style.zIndex = String(topPortalZ());
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const pr = _pickerEl.getBoundingClientRect();
|
const pr = _pickerEl.getBoundingClientRect();
|
||||||
|
|||||||
+7
-11
@@ -6,6 +6,8 @@ import uiModule from './ui.js';
|
|||||||
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
|
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
import { topPortalZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _open = false;
|
let _open = false;
|
||||||
@@ -2514,7 +2516,7 @@ export function openGallery() {
|
|||||||
// shares the exact same dropdown style/behaviour.
|
// shares the exact same dropdown style/behaviour.
|
||||||
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
|
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
|
||||||
function _showGalleryBulkMenu(anchor) {
|
function _showGalleryBulkMenu(anchor) {
|
||||||
document.querySelectorAll('.gallery-bulk-menu').forEach(d => d.remove());
|
document.querySelectorAll('.gallery-bulk-menu').forEach(dismissOrRemove);
|
||||||
// Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
|
// Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
|
||||||
// matches every other menu in the app. Positioned fixed at the button.
|
// matches every other menu in the app. Positioned fixed at the button.
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
@@ -2523,7 +2525,7 @@ export function openGallery() {
|
|||||||
const left = Math.min(rect.left, window.innerWidth - 200);
|
const left = Math.min(rect.left, window.innerWidth - 200);
|
||||||
// Inline the standard dropdown look so it renders correctly even where the
|
// Inline the standard dropdown look so it renders correctly even where the
|
||||||
// `.dropdown` rule is scoped out (e.g. hover-only media queries on mobile).
|
// `.dropdown` rule is scoped out (e.g. hover-only media queries on mobile).
|
||||||
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||||
const _favIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.7-4.35-9.33-8.04C.9 10.3 1.4 6.9 4.1 5.6c1.9-.9 4 .03 5 1.7 1-1.67 3.1-2.6 5-1.7 2.7 1.3 3.2 4.7 1.43 7.36C18.7 16.65 12 21 12 21z"/></svg>';
|
const _favIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.7-4.35-9.33-8.04C.9 10.3 1.4 6.9 4.1 5.6c1.9-.9 4 .03 5 1.7 1-1.67 3.1-2.6 5-1.7 2.7 1.3 3.2 4.7 1.43 7.36C18.7 16.65 12 21 12 21z"/></svg>';
|
||||||
const _tagIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>';
|
const _tagIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>';
|
||||||
const _dlIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
const _dlIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||||
@@ -2548,17 +2550,11 @@ export function openGallery() {
|
|||||||
const it = document.createElement('div');
|
const it = document.createElement('div');
|
||||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||||
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
|
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
|
||||||
dropdown.appendChild(it);
|
dropdown.appendChild(it);
|
||||||
}
|
}
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
|
||||||
dropdown.remove();
|
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_bulkActionsBtn?.addEventListener('click', (e) => {
|
_bulkActionsBtn?.addEventListener('click', (e) => {
|
||||||
@@ -2567,7 +2563,7 @@ export function openGallery() {
|
|||||||
// should close it. The outside-click handler explicitly skips clicks on
|
// should close it. The outside-click handler explicitly skips clicks on
|
||||||
// the anchor, so the button itself has to do its own dismiss.
|
// the anchor, so the button itself has to do its own dismiss.
|
||||||
const existing = document.querySelector('.gallery-bulk-menu');
|
const existing = document.querySelector('.gallery-bulk-menu');
|
||||||
if (existing) { existing.remove(); return; }
|
if (existing) { dismissOrRemove(existing); return; }
|
||||||
if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
|
if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
|
||||||
_showGalleryBulkMenu(e.currentTarget);
|
_showGalleryBulkMenu(e.currentTarget);
|
||||||
});
|
});
|
||||||
|
|||||||
+22
-6
@@ -483,6 +483,7 @@ export function processWithThinking(text) {
|
|||||||
export function mdToHtml(src, opts) {
|
export function mdToHtml(src, opts) {
|
||||||
const allowedHtmlBlocks = [];
|
const allowedHtmlBlocks = [];
|
||||||
const codeBlocks = [];
|
const codeBlocks = [];
|
||||||
|
const inlineCodeBlocks = [];
|
||||||
const mermaidBlocks = [];
|
const mermaidBlocks = [];
|
||||||
let s = (src ?? '');
|
let s = (src ?? '');
|
||||||
|
|
||||||
@@ -521,6 +522,19 @@ export function mdToHtml(src, opts) {
|
|||||||
return placeholder;
|
return placeholder;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Extract inline code spans before the link/autolink/HTML passes, mirroring
|
||||||
|
// the fenced-block handling above. A URL inside `inline code` (e.g.
|
||||||
|
// `irm http://127.0.0.1:3000/x`) is preceded by a space, so the bare-URL
|
||||||
|
// autolink matches it, wraps it in an <a> tag, and swaps that for an
|
||||||
|
// ___ALLOWED_HTML_ placeholder — corrupting the command. The old inline-code
|
||||||
|
// pass ran after those passes, too late to protect it.
|
||||||
|
s = s.replace(/`([^`]+?)`/g, (match, code) => {
|
||||||
|
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___MERMAID_BLOCK_')) return match;
|
||||||
|
const placeholder = `___INLINE_CODE_${inlineCodeBlocks.length}___`;
|
||||||
|
inlineCodeBlocks.push(`<code>${escapeHtml(code)}</code>`);
|
||||||
|
return placeholder;
|
||||||
|
});
|
||||||
|
|
||||||
// Repair common ways the agent mangles the entity-anchor convention
|
// Repair common ways the agent mangles the entity-anchor convention
|
||||||
// (`[Name](#kind-<id>)`). Models reliably get the single-link case
|
// (`[Name](#kind-<id>)`). Models reliably get the single-link case
|
||||||
// right but slip into other formats when listing many in a table.
|
// right but slip into other formats when listing many in a table.
|
||||||
@@ -678,12 +692,6 @@ export function mdToHtml(src, opts) {
|
|||||||
return html;
|
return html;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inline code (but not placeholders)
|
|
||||||
s = s.replace(/`([^`]+?)`/g, (match, code) => {
|
|
||||||
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___ALLOWED_HTML_')) return match;
|
|
||||||
return `<code>${code}</code>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Horizontal rules (must come before bold/italic to avoid * conflicts)
|
// Horizontal rules (must come before bold/italic to avoid * conflicts)
|
||||||
s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr>');
|
s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr>');
|
||||||
|
|
||||||
@@ -756,6 +764,14 @@ export function mdToHtml(src, opts) {
|
|||||||
s = s.replace(`___CODE_BLOCK_${index}___`, block);
|
s = s.replace(`___CODE_BLOCK_${index}___`, block);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restore inline code spans last, so placeholders carried inside restored
|
||||||
|
// <a>/allowed-HTML blocks are resolved too. The function replacer keeps the
|
||||||
|
// escaped code literal — e.g. a shell snippet like `echo $1` is not treated
|
||||||
|
// as a regex back-reference.
|
||||||
|
inlineCodeBlocks.forEach((block, index) => {
|
||||||
|
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
|
||||||
|
});
|
||||||
|
|
||||||
return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
|
return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -6,6 +6,7 @@ import sessionModule from './sessions.js';
|
|||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
import { snapModalToZone } from './tileManager.js';
|
import { snapModalToZone } from './tileManager.js';
|
||||||
|
import { topPortalZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
var escapeHtml = uiModule.esc;
|
var escapeHtml = uiModule.esc;
|
||||||
|
|
||||||
@@ -865,7 +866,13 @@ export function renderMemoryList() {
|
|||||||
dropdown.style.top = rect.bottom + 2 + 'px';
|
dropdown.style.top = rect.bottom + 2 + 'px';
|
||||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
||||||
dropdown.style.left = 'auto';
|
dropdown.style.left = 'auto';
|
||||||
dropdown.style.zIndex = '10001';
|
// Portaled to <body>, so it must outrank the Brain modal it belongs to.
|
||||||
|
// Tool modals get a monotonically increasing z-index from modalManager's
|
||||||
|
// bring-to-front counter, which climbs unbounded over a long session —
|
||||||
|
// once it passed the old hardcoded 10001 the menu rendered behind the
|
||||||
|
// panel (#4720). topPortalZ() derives the value from the live tool-window
|
||||||
|
// stack so the menu always sits just above, however high it has climbed.
|
||||||
|
dropdown.style.zIndex = String(topPortalZ());
|
||||||
dropdown.style.display = 'block';
|
dropdown.style.display = 'block';
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
// Keep on-screen (mobile): flip above the button if it overflows the
|
// Keep on-screen (mobile): flip above the button if it overflows the
|
||||||
|
|||||||
+11
-18
@@ -10,7 +10,8 @@ import { attachColorPicker } from './colorPicker.js';
|
|||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
import { snapModalToZone } from './tileManager.js';
|
import { snapModalToZone } from './tileManager.js';
|
||||||
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
||||||
import { topToolWindowZ } from './toolWindowZOrder.js';
|
import { topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _open = false;
|
let _open = false;
|
||||||
@@ -3360,7 +3361,7 @@ function _buildForm(note = null) {
|
|||||||
|
|
||||||
function _pickCustomDate() {
|
function _pickCustomDate() {
|
||||||
// Replace the dropdown menu with a small inline picker
|
// Replace the dropdown menu with a small inline picker
|
||||||
document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove());
|
document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove);
|
||||||
const menu = document.createElement('div');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'note-reminder-menu';
|
menu.className = 'note-reminder-menu';
|
||||||
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
|
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
|
||||||
@@ -3394,14 +3395,11 @@ function _buildForm(note = null) {
|
|||||||
if (typeof dInput.showPicker === 'function') {
|
if (typeof dInput.showPicker === 'function') {
|
||||||
try { dInput.showPicker(); } catch {}
|
try { dInput.showPicker(); } catch {}
|
||||||
}
|
}
|
||||||
|
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||||
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
|
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
|
||||||
if (dInput.value) _setReminder(dInput.value);
|
if (dInput.value) _setReminder(dInput.value);
|
||||||
menu.remove();
|
close();
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
|
||||||
const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } };
|
|
||||||
document.addEventListener('click', close);
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); });
|
if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); });
|
||||||
@@ -4311,7 +4309,7 @@ function _serializeNoteForCopy(note) {
|
|||||||
// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
|
// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
|
||||||
// ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
|
// ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
|
||||||
function _openNoteCornerMenu(btn) {
|
function _openNoteCornerMenu(btn) {
|
||||||
document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove());
|
document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove);
|
||||||
const id = btn.dataset.noteId;
|
const id = btn.dataset.noteId;
|
||||||
const note = _notes.find(n => n.id === id);
|
const note = _notes.find(n => n.id === id);
|
||||||
if (!note) return;
|
if (!note) return;
|
||||||
@@ -4337,15 +4335,10 @@ function _openNoteCornerMenu(btn) {
|
|||||||
const mh = menu.offsetHeight || 96;
|
const mh = menu.offsetHeight || 96;
|
||||||
const below = window.innerHeight - r.bottom;
|
const below = window.innerHeight - r.bottom;
|
||||||
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
||||||
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`;
|
menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`;
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||||
if (ev && menu.contains(ev.target)) return;
|
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
|
||||||
menu.remove();
|
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
|
||||||
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { menu.remove(); _copyNote(id, btn); });
|
|
||||||
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { menu.remove(); _agentSolveNote(id); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _positionNoteMenu(menu, btn, width = 196) {
|
function _positionNoteMenu(menu, btn, width = 196) {
|
||||||
@@ -4356,7 +4349,7 @@ function _positionNoteMenu(menu, btn, width = 196) {
|
|||||||
const mh = menu.offsetHeight || 112;
|
const mh = menu.offsetHeight || 112;
|
||||||
const below = window.innerHeight - r.bottom;
|
const below = window.innerHeight - r.bottom;
|
||||||
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
||||||
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
|
menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
|
||||||
const close = (ev) => {
|
const close = (ev) => {
|
||||||
if (ev && menu.contains(ev.target)) return;
|
if (ev && menu.contains(ev.target)) return;
|
||||||
menu.remove();
|
menu.remove();
|
||||||
|
|||||||
+12
-3
@@ -133,11 +133,20 @@ export function providerLabel(endpointUrl) {
|
|||||||
try {
|
try {
|
||||||
host = new URL(endpointUrl).hostname;
|
host = new URL(endpointUrl).hostname;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Not a full URL (e.g. bare host[:port]) — strip scheme/path/port best-effort.
|
// Not a full URL (e.g. bare host[:port]) — strip scheme/path best-effort.
|
||||||
host = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0];
|
const stripped = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0];
|
||||||
|
const colonIdx = stripped.lastIndexOf(":");
|
||||||
|
host = colonIdx >= 0 ? stripped.slice(0, colonIdx) : stripped;
|
||||||
}
|
}
|
||||||
if (!host) return null;
|
if (!host) return null;
|
||||||
if (/^(localhost|127\.|0\.0\.0\.0|::1|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) {
|
const isLoopback = /^(localhost|127\.|0\.0\.0\.0|::1)/.test(host);
|
||||||
|
if (isLoopback) {
|
||||||
|
// Don't name the serving tool from the port — it isn't authoritative
|
||||||
|
// (vLLM/SGLang/llama.cpp share 8000/8080). Discovery identifies the tool by
|
||||||
|
// probing /props and stores the result as the endpoint's name instead.
|
||||||
|
return "Local";
|
||||||
|
}
|
||||||
|
if (/^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) {
|
||||||
return "Local";
|
return "Local";
|
||||||
}
|
}
|
||||||
for (const [re, label] of _ENDPOINT_LABELS) {
|
for (const [re, label] of _ENDPOINT_LABELS) {
|
||||||
|
|||||||
@@ -1938,6 +1938,7 @@ async function _onSessionListKeydown(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
if (item.querySelector('.session-rename-input')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const sid = item.dataset.sessionId;
|
const sid = item.dataset.sessionId;
|
||||||
const s = sessions.find(x => x.id === sid);
|
const s = sessions.find(x => x.id === sid);
|
||||||
|
|||||||
+16
-9
@@ -8,6 +8,7 @@ import { clearDockSide } from './modalSnap.js';
|
|||||||
import { sortModelIds } from './modelSort.js';
|
import { sortModelIds } from './modelSort.js';
|
||||||
import { providerLogo } from './providers.js';
|
import { providerLogo } from './providers.js';
|
||||||
import { isAltGrEvent } from './platform.js';
|
import { isAltGrEvent } from './platform.js';
|
||||||
|
import { bindMenuDismiss } from './escMenuStack.js';
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let modalEl = null;
|
let modalEl = null;
|
||||||
@@ -3838,7 +3839,10 @@ async function initUnifiedIntegrations() {
|
|||||||
if (lbl) lbl.textContent = text;
|
if (lbl) lbl.textContent = text;
|
||||||
if (ico) ico.innerHTML = _apiIconFor(k);
|
if (ico) ico.innerHTML = _apiIconFor(k);
|
||||||
};
|
};
|
||||||
const _close = () => { menu.style.display = 'none'; };
|
// Menu is reused (hidden, not recreated). close() hides it and tears down
|
||||||
|
// its outside-click listener + Escape-stack entry; bindMenuDismiss is
|
||||||
|
// re-registered fresh on each open (see _open).
|
||||||
|
let _close = () => { menu.style.display = 'none'; };
|
||||||
const _open = () => {
|
const _open = () => {
|
||||||
menu.style.display = 'block';
|
menu.style.display = 'block';
|
||||||
const tRect = trig.getBoundingClientRect();
|
const tRect = trig.getBoundingClientRect();
|
||||||
@@ -3847,8 +3851,7 @@ async function initUnifiedIntegrations() {
|
|||||||
const above = tRect.top;
|
const above = tRect.top;
|
||||||
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
|
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
|
||||||
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
|
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
|
||||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } };
|
_close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig);
|
||||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
|
||||||
};
|
};
|
||||||
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
|
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
|
||||||
menu.querySelectorAll('.ufapi-option').forEach(btn => {
|
menu.querySelectorAll('.ufapi-option').forEach(btn => {
|
||||||
@@ -4584,7 +4587,10 @@ async function initUnifiedIntegrations() {
|
|||||||
if (labelEl) labelEl.textContent = lbl;
|
if (labelEl) labelEl.textContent = lbl;
|
||||||
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
|
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
|
||||||
};
|
};
|
||||||
const _closeMenu = () => { menu.style.display = 'none'; };
|
// Menu is reused (hidden, not recreated). _closeMenu hides it and tears
|
||||||
|
// down its outside-click listener + Escape-stack entry; bindMenuDismiss is
|
||||||
|
// re-registered fresh on each open (see _openMenu).
|
||||||
|
let _closeMenu = () => { menu.style.display = 'none'; };
|
||||||
const _openMenu = () => {
|
const _openMenu = () => {
|
||||||
menu.style.display = 'block';
|
menu.style.display = 'block';
|
||||||
// Drop-up when there's not enough room below the trigger.
|
// Drop-up when there's not enough room below the trigger.
|
||||||
@@ -4597,8 +4603,7 @@ async function initUnifiedIntegrations() {
|
|||||||
} else {
|
} else {
|
||||||
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
|
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
|
||||||
}
|
}
|
||||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
|
_closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger);
|
||||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
|
||||||
};
|
};
|
||||||
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
|
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
|
||||||
menu.querySelectorAll('.ufp-option').forEach(btn => {
|
menu.querySelectorAll('.ufp-option').forEach(btn => {
|
||||||
@@ -5650,8 +5655,11 @@ async function initUnifiedIntegrations() {
|
|||||||
addBtn.parentElement.style.position = 'relative';
|
addBtn.parentElement.style.position = 'relative';
|
||||||
addBtn.parentElement.classList.add('uf-add-anchor');
|
addBtn.parentElement.classList.add('uf-add-anchor');
|
||||||
}
|
}
|
||||||
|
// Menu is created per open and removed on close. _closeMenu routes through
|
||||||
|
// the bindMenuDismiss close() bound when the menu opens, so the outside-click
|
||||||
|
// listener + Escape-stack entry are torn down alongside the node removal.
|
||||||
let _menuEl = null;
|
let _menuEl = null;
|
||||||
const _closeMenu = () => { if (_menuEl) { _menuEl.remove(); _menuEl = null; } };
|
let _closeMenu = () => {};
|
||||||
addBtn.addEventListener('click', (e) => {
|
addBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (_menuEl) { _closeMenu(); return; }
|
if (_menuEl) { _closeMenu(); return; }
|
||||||
@@ -5683,8 +5691,7 @@ async function initUnifiedIntegrations() {
|
|||||||
showForm(k, 'new');
|
showForm(k, 'new');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== addBtn) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
|
_closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn);
|
||||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-7
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import * as spinnerModule from './spinner.js';
|
import * as spinnerModule from './spinner.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
import { topPortalZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
const API = window.location.origin;
|
const API = window.location.origin;
|
||||||
let skills = [];
|
let skills = [];
|
||||||
@@ -391,14 +393,14 @@ function _svg(paths, { fill = 'none', size = 13 } = {}) {
|
|||||||
// Kebab dropdown for a collapsed skill card — same actions + icons as the
|
// Kebab dropdown for a collapsed skill card — same actions + icons as the
|
||||||
// expanded footer (Publish/Unpublish · Edit · Delete).
|
// expanded footer (Publish/Unpublish · Edit · Delete).
|
||||||
function _openSkillMenu(btn, card, sk, name, isPublished) {
|
function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||||
document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove());
|
document.querySelectorAll('.skill-kebab-menu').forEach(dismissOrRemove);
|
||||||
const menu = document.createElement('div');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'skill-kebab-menu';
|
menu.className = 'skill-kebab-menu';
|
||||||
const mk = (paths, label, opts, onClick) => {
|
const mk = (paths, label, opts, onClick) => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
|
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
|
||||||
item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`;
|
item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`;
|
||||||
item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); });
|
item.addEventListener('click', (e) => { e.stopPropagation(); close(); onClick(); });
|
||||||
menu.appendChild(item);
|
menu.appendChild(item);
|
||||||
};
|
};
|
||||||
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
|
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
|
||||||
@@ -410,7 +412,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
|||||||
selItem.innerHTML = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span>Select</span>';
|
selItem.innerHTML = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span>Select</span>';
|
||||||
selItem.addEventListener('click', (e) => {
|
selItem.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
menu.remove();
|
close();
|
||||||
if (!_selectMode) _enterSelectMode();
|
if (!_selectMode) _enterSelectMode();
|
||||||
_selectedNames.add(name);
|
_selectedNames.add(name);
|
||||||
renderSkillsList();
|
renderSkillsList();
|
||||||
@@ -432,10 +434,14 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
|||||||
const cancelItem = document.createElement('button');
|
const cancelItem = document.createElement('button');
|
||||||
cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
|
cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
|
||||||
cancelItem.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Cancel</span>';
|
cancelItem.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Cancel</span>';
|
||||||
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); });
|
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); close(); });
|
||||||
menu.appendChild(cancelItem);
|
menu.appendChild(cancelItem);
|
||||||
|
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
|
// Override the CSS z-index (100002) with a value derived from the live
|
||||||
|
// tool-window stack so the kebab menu stays above its modal even after the
|
||||||
|
// bring-to-front counter climbs past the static value (#4720).
|
||||||
|
menu.style.zIndex = String(topPortalZ());
|
||||||
const r = btn.getBoundingClientRect();
|
const r = btn.getBoundingClientRect();
|
||||||
menu.style.top = (r.bottom + 4) + 'px';
|
menu.style.top = (r.bottom + 4) + 'px';
|
||||||
menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px';
|
menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px';
|
||||||
@@ -453,8 +459,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
|||||||
menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
|
menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
|
||||||
menu.style.overflowY = 'auto';
|
menu.style.overflowY = 'auto';
|
||||||
}
|
}
|
||||||
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } };
|
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target));
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cards for the agent's built-in tool capabilities (from
|
// Cards for the agent's built-in tool capabilities (from
|
||||||
@@ -1802,7 +1807,7 @@ async function _showSkillSource(name) {
|
|||||||
wrap.className = 'modal';
|
wrap.className = 'modal';
|
||||||
wrap.style.display = 'block';
|
wrap.style.display = 'block';
|
||||||
wrap.innerHTML = `
|
wrap.innerHTML = `
|
||||||
<div class="modal-content" style="max-width:760px;max-height:85vh;display:flex;flex-direction:column">
|
<div class="modal-content" style="max-width:760px;display:flex;flex-direction:column">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4>SKILL.md — <code>${esc(name)}</code></h4>
|
<h4>SKILL.md — <code>${esc(name)}</code></h4>
|
||||||
<span style="flex:1"></span>
|
<span style="flex:1"></span>
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ function _setupProviderFromInput(input) {
|
|||||||
xai: 'xai',
|
xai: 'xai',
|
||||||
grok: 'xai',
|
grok: 'xai',
|
||||||
nvidia: 'nvidia',
|
nvidia: 'nvidia',
|
||||||
|
opencodezen: 'opencode-zen',
|
||||||
|
opencodego: 'opencode-go',
|
||||||
};
|
};
|
||||||
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
|
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
|
||||||
}
|
}
|
||||||
@@ -129,6 +131,8 @@ function _extractSetupProviderCredential(input) {
|
|||||||
['google', 'gemini'], ['gemini', 'gemini'],
|
['google', 'gemini'], ['gemini', 'gemini'],
|
||||||
['x ai', 'xai'], ['xai', 'xai'], ['grok', 'xai'],
|
['x ai', 'xai'], ['xai', 'xai'], ['grok', 'xai'],
|
||||||
['nvidia', 'nvidia'],
|
['nvidia', 'nvidia'],
|
||||||
|
['opencode zen', 'opencode-zen'], ['opencode-zen', 'opencode-zen'],
|
||||||
|
['opencode go', 'opencode-go'], ['opencode-go', 'opencode-go'],
|
||||||
];
|
];
|
||||||
for (const [alias, key] of providerAliases) {
|
for (const [alias, key] of providerAliases) {
|
||||||
const re = new RegExp('(^|\\s|[,;:])(' + alias.replace(/\s+/g, '\\s+') + ')(?=$|\\s|[,;:])', 'i');
|
const re = new RegExp('(^|\\s|[,;:])(' + alias.replace(/\s+/g, '\\s+') + ')(?=$|\\s|[,;:])', 'i');
|
||||||
@@ -204,6 +208,8 @@ function _showSetupEndpointChoices() {
|
|||||||
'<pre style="margin:4px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://localhost:11434/v1</code></pre>' +
|
'<pre style="margin:4px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://localhost:11434/v1</code></pre>' +
|
||||||
'<div style="margin-top:4px;">or</div>' +
|
'<div style="margin-top:4px;">or</div>' +
|
||||||
'<pre style="margin:2px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://llm-host.local:8000/v1</code></pre>' +
|
'<pre style="margin:2px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://llm-host.local:8000/v1</code></pre>' +
|
||||||
|
'<div style="margin-top:4px;">or llama.cpp (llama-server):</div>' +
|
||||||
|
'<pre style="margin:2px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://localhost:8080/v1</code></pre>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div style="border:1px solid var(--border);border-radius:8px;padding:10px 12px;background:color-mix(in srgb,var(--bg) 88%,var(--fg) 12%);">' +
|
'<div style="border:1px solid var(--border);border-radius:8px;padding:10px 12px;background:color-mix(in srgb,var(--bg) 88%,var(--fg) 12%);">' +
|
||||||
'<div style="font-weight:700;margin-bottom:6px;">' + SETUP_API_ICON + 'API setup</div>' +
|
'<div style="font-weight:700;margin-bottom:6px;">' + SETUP_API_ICON + 'API setup</div>' +
|
||||||
@@ -234,6 +240,12 @@ function _showSetupEndpointChoicesStreamed(options = {}) {
|
|||||||
text: 'http://llm-host.local:8000/v1',
|
text: 'http://llm-host.local:8000/v1',
|
||||||
copyText: 'http://llm-host.local:8000/v1',
|
copyText: 'http://llm-host.local:8000/v1',
|
||||||
},
|
},
|
||||||
|
{ kind: 'p', text: 'or llama.cpp (llama-server):' },
|
||||||
|
{
|
||||||
|
kind: 'code',
|
||||||
|
text: 'http://localhost:8080/v1',
|
||||||
|
copyText: 'http://localhost:8080/v1',
|
||||||
|
},
|
||||||
{ kind: 'heading', html: SETUP_API_ICON + 'API setup' },
|
{ kind: 'heading', html: SETUP_API_ICON + 'API setup' },
|
||||||
{ kind: 'p', text: 'Paste provider name then API key (example):' },
|
{ kind: 'p', text: 'Paste provider name then API key (example):' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const KEYS = {
|
|||||||
SECTION_ORDER: 'sidebar-section-order',
|
SECTION_ORDER: 'sidebar-section-order',
|
||||||
ADMIN_LAST_TAB: 'admin-last-tab',
|
ADMIN_LAST_TAB: 'admin-last-tab',
|
||||||
DENSITY: 'odysseus-density',
|
DENSITY: 'odysseus-density',
|
||||||
|
UI_SCALE: 'odysseus-ui-scale',
|
||||||
WORKSPACE: 'odysseus-workspace'
|
WORKSPACE: 'odysseus-workspace'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+14
-11
@@ -6,8 +6,10 @@ import uiModule from './ui.js';
|
|||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
import * as spinnerModule from './spinner.js';
|
import * as spinnerModule from './spinner.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
|
import { topPortalZ } from './toolWindowZOrder.js';
|
||||||
import { sortModelIds } from './modelSort.js';
|
import { sortModelIds } from './modelSort.js';
|
||||||
import { ordinalSuffix } from './util/ordinal.js';
|
import { ordinalSuffix } from './util/ordinal.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _open = false;
|
let _open = false;
|
||||||
@@ -899,10 +901,10 @@ function _attachTaskLongPress(card, menuBtn) {
|
|||||||
|
|
||||||
function _showTaskDropdown(anchor, items) {
|
function _showTaskDropdown(anchor, items) {
|
||||||
// Remove any existing dropdown
|
// Remove any existing dropdown
|
||||||
document.querySelectorAll('.task-dropdown').forEach(d => d.remove());
|
document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove);
|
||||||
const dd = document.createElement('div');
|
const dd = document.createElement('div');
|
||||||
dd.className = 'task-dropdown';
|
dd.className = 'task-dropdown';
|
||||||
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
|
dd.style.cssText = 'position:fixed;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 10px;border:none;background:none;color:var(--fg);font-size:11px;font-family:inherit;cursor:pointer;border-radius:4px;transition:background 0.1s;';
|
btn.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 10px;border:none;background:none;color:var(--fg);font-size:11px;font-family:inherit;cursor:pointer;border-radius:4px;transition:background 0.1s;';
|
||||||
@@ -914,10 +916,14 @@ function _showTaskDropdown(anchor, items) {
|
|||||||
}
|
}
|
||||||
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
||||||
btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; });
|
btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; });
|
||||||
btn.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); });
|
btn.addEventListener('click', (e) => { e.stopPropagation(); close(); item.action(); });
|
||||||
dd.appendChild(btn);
|
dd.appendChild(btn);
|
||||||
});
|
});
|
||||||
document.body.appendChild(dd);
|
document.body.appendChild(dd);
|
||||||
|
// Sit above the currently-raised tool modal at any stack depth (#4720): the
|
||||||
|
// modal bring-to-front counter climbs unbounded, so a hardcoded z eventually
|
||||||
|
// loses. topPortalZ() derives the value from the live tool-window stack.
|
||||||
|
dd.style.zIndex = String(topPortalZ());
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
let top = rect.bottom + 4;
|
let top = rect.bottom + 4;
|
||||||
let left = rect.right - dd.offsetWidth;
|
let left = rect.right - dd.offsetWidth;
|
||||||
@@ -926,16 +932,13 @@ function _showTaskDropdown(anchor, items) {
|
|||||||
dd.style.top = top + 'px';
|
dd.style.top = top + 'px';
|
||||||
dd.style.left = left + 'px';
|
dd.style.left = left + 'px';
|
||||||
const openedAt = performance.now();
|
const openedAt = performance.now();
|
||||||
const close = (e) => {
|
const close = bindMenuDismiss(dd, () => { dd.remove(); }, (ev) => {
|
||||||
// Ignore any clicks that occur within 250ms of the open (covers touch
|
// Ignore any clicks that occur within 250ms of the open (covers touch
|
||||||
// "ghost click" duplicates that were firing right after pointerup and
|
// "ghost click" duplicates that were firing right after pointerup and
|
||||||
// removing the dropdown before the user could see it).
|
// removing the dropdown before the user could see it) — treat as inside.
|
||||||
if (performance.now() - openedAt < 250) return;
|
if (performance.now() - openedAt < 250) return false;
|
||||||
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); }
|
return !dd.contains(ev.target);
|
||||||
};
|
});
|
||||||
// requestAnimationFrame so the listener is registered AFTER the current
|
|
||||||
// pointer/click event cycle has finished bubbling.
|
|
||||||
requestAnimationFrame(() => document.addEventListener('click', close));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Presets ----
|
// ---- Presets ----
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const FONT_MAP = {
|
|||||||
mono: "'Fira Code', monospace",
|
mono: "'Fira Code', monospace",
|
||||||
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
|
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
|
||||||
serif: "Georgia, 'Times New Roman', serif",
|
serif: "Georgia, 'Times New Roman', serif",
|
||||||
|
opendyslexic: "'OpenDyslexic', sans-serif",
|
||||||
};
|
};
|
||||||
const DEFAULT_FONT = 'mono';
|
const DEFAULT_FONT = 'mono';
|
||||||
const DEFAULT_DENSITY = 'comfortable';
|
const DEFAULT_DENSITY = 'comfortable';
|
||||||
@@ -387,6 +388,20 @@ export function applyFontDensity(font, density) {
|
|||||||
if (d !== 'comfortable') document.documentElement.classList.add('density-' + d);
|
if (d !== 'comfortable') document.documentElement.classList.add('density-' + d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UI text-size scale (accessibility). Global and independent of the active
|
||||||
|
// theme, so the chosen size persists across theme switches. Stored as a plain
|
||||||
|
// percentage string ('100' | '110' | '125' | '150').
|
||||||
|
const UI_SCALE_KEY = 'odysseus-ui-scale';
|
||||||
|
const DEFAULT_UI_SCALE = '100';
|
||||||
|
|
||||||
|
export function applyUiScale(scale) {
|
||||||
|
const s = scale || DEFAULT_UI_SCALE;
|
||||||
|
// Only one non-default scale ('125'). Remove any legacy classes too so an
|
||||||
|
// older stored value can't leave a stale zoom applied.
|
||||||
|
document.documentElement.classList.remove('ui-scale-110', 'ui-scale-125', 'ui-scale-140');
|
||||||
|
if (s === '125') document.documentElement.classList.add('ui-scale-125');
|
||||||
|
}
|
||||||
|
|
||||||
const _BG_CLASSES = ['bg-pattern-dots',
|
const _BG_CLASSES = ['bg-pattern-dots',
|
||||||
'bg-pattern-synapse', 'bg-pattern-rain', 'bg-pattern-constellations',
|
'bg-pattern-synapse', 'bg-pattern-rain', 'bg-pattern-constellations',
|
||||||
'bg-pattern-perlin-flow',
|
'bg-pattern-perlin-flow',
|
||||||
@@ -1133,6 +1148,18 @@ export function initThemeUI() {
|
|||||||
const s = getSaved(); if (s) _saveFull(s.name, s.colors);
|
const s = getSaved(); if (s) _saveFull(s.name, s.colors);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const textSizeSelect = document.getElementById('theme-text-size-select');
|
||||||
|
if (textSizeSelect) {
|
||||||
|
const nts = textSizeSelect.cloneNode(true); textSizeSelect.parentNode.replaceChild(nts, textSizeSelect);
|
||||||
|
let initScale = DEFAULT_UI_SCALE;
|
||||||
|
try { initScale = localStorage.getItem(UI_SCALE_KEY) || DEFAULT_UI_SCALE; } catch (e) {}
|
||||||
|
nts.value = initScale;
|
||||||
|
applyUiScale(initScale);
|
||||||
|
nts.addEventListener('change', () => {
|
||||||
|
applyUiScale(nts.value);
|
||||||
|
try { localStorage.setItem(UI_SCALE_KEY, nts.value); } catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
if (patternSelect) {
|
if (patternSelect) {
|
||||||
const np = patternSelect.cloneNode(true); patternSelect.parentNode.replaceChild(np, patternSelect);
|
const np = patternSelect.cloneNode(true); patternSelect.parentNode.replaceChild(np, patternSelect);
|
||||||
np.value = _initPattern;
|
np.value = _initPattern;
|
||||||
|
|||||||
@@ -27,3 +27,20 @@ export function nextToolWindowZ(options = {}) {
|
|||||||
if (Number.isFinite(currentZ) && currentZ > top) return currentZ;
|
if (Number.isFinite(currentZ) && currentZ > top) return currentZ;
|
||||||
return top + 1;
|
return top + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dock chips pinned by the minimized-dock drag interactions reach z 10030
|
||||||
|
// (free-drag) / 10020 (mobile rest) — see modalManager.js. A body-portaled
|
||||||
|
// dropdown has to clear those too, not just the open tool-window stack, so this
|
||||||
|
// floor keeps it above a chip even when no modal is currently raised.
|
||||||
|
const DOCK_OVERLAY_FLOOR = 10030;
|
||||||
|
|
||||||
|
// The z a body-portaled dropdown/menu needs so it always sits just above every
|
||||||
|
// open tool window (and the dock chips) right now. Tool modals get a
|
||||||
|
// monotonically increasing z from the bring-to-front counter (modalManager),
|
||||||
|
// which climbs unbounded over a long session — so the hardcoded `z-index: 10001`
|
||||||
|
// these dropdowns historically used eventually rendered them BEHIND their own
|
||||||
|
// modal (#4720). Derive the value from the live stack instead, sharing the same
|
||||||
|
// single source of truth as nextToolWindowZ().
|
||||||
|
export function topPortalZ(options = {}) {
|
||||||
|
return Math.max(topToolWindowZ(options), DOCK_OVERLAY_FLOOR) + 1;
|
||||||
|
}
|
||||||
|
|||||||
+45
-1
@@ -114,6 +114,10 @@ body {
|
|||||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||||
|
|
||||||
|
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
|
||||||
|
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
|
||||||
|
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
|
||||||
|
|
||||||
/* Code block baseline */
|
/* Code block baseline */
|
||||||
pre, code, .hljs {
|
pre, code, .hljs {
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
@@ -158,6 +162,39 @@ html {
|
|||||||
:root.density-spacious .list-item { padding: 8px 12px; }
|
:root.density-spacious .list-item { padding: 8px 12px; }
|
||||||
:root.density-spacious .sidebar .section { padding: 0; }
|
:root.density-spacious .sidebar .section { padding: 0; }
|
||||||
|
|
||||||
|
/* ── UI text-size scale (accessibility) ──
|
||||||
|
Density only changes the root font-size, which can't move the many
|
||||||
|
hard-coded px sizes. `zoom` scales the whole UI uniformly (px text
|
||||||
|
included) while keeping layout intact, unlike `transform: scale`. */
|
||||||
|
:root.ui-scale-125 { zoom: 1.25; }
|
||||||
|
/* `zoom` makes the 100dvh shell render taller than the real viewport, which
|
||||||
|
pushes the bottom-pinned sidebar account/settings row below the fold (and
|
||||||
|
body's overflow:hidden then clips it). Shrink the shell by the same factor
|
||||||
|
so it fits the viewport exactly. */
|
||||||
|
:root.ui-scale-125 body { height: calc(100dvh / 1.25); }
|
||||||
|
/* Modals/panels under the 1.25x scale: zoom renders a centred, viewport-sized
|
||||||
|
panel ~1.25x taller, pushing its draggable header + close button off-screen
|
||||||
|
(a catch-22 — you can't reach the control to turn the size back down). Divide
|
||||||
|
each max-height by the same factor to keep the original on-screen footprint.
|
||||||
|
Desktop only — the mobile `!important` full-sheet rules win on small screens
|
||||||
|
and stay top-anchored, so their headers are already visible. */
|
||||||
|
:root.ui-scale-125 .modal-content { max-height: calc(85dvh / 1.25); }
|
||||||
|
:root.ui-scale-125 .cal-modal-content { max-height: calc(88dvh / 1.25); }
|
||||||
|
:root.ui-scale-125 .settings-modal-content { max-height: calc(85dvh / 1.25); }
|
||||||
|
:root.ui-scale-125 #theme-popup { max-height: min(calc(85dvh / 1.25), 480px); }
|
||||||
|
/* Cookbook is the one modal that set its height inline (94vh), which beat the
|
||||||
|
.modal-content compensation above and overflowed the viewport at 1.25x
|
||||||
|
(header + close button pushed off-screen). Own its height here so the same
|
||||||
|
zoom compensation applies. */
|
||||||
|
#cookbook-modal .modal-content { height: 94vh; max-height: 94vh; }
|
||||||
|
:root.ui-scale-125 #cookbook-modal .modal-content { height: calc(94dvh / 1.25); max-height: calc(94dvh / 1.25); }
|
||||||
|
/* PDF export modal also set its height inline (86vh) at v1.0; that inline cap
|
||||||
|
beat the .modal-content compensation above and shifted ~1vh at Default when
|
||||||
|
removed. Own its height here so Default is byte-for-byte 86vh and the same
|
||||||
|
1.25x compensation applies. */
|
||||||
|
.pdf-export-overlay .modal-content { max-height: 86vh; }
|
||||||
|
:root.ui-scale-125 .pdf-export-overlay .modal-content { max-height: calc(86dvh / 1.25); }
|
||||||
|
|
||||||
/* ── Background Patterns ── */
|
/* ── Background Patterns ── */
|
||||||
|
|
||||||
:root { --bg-effect-intensity: 1; }
|
:root { --bg-effect-intensity: 1; }
|
||||||
@@ -8627,6 +8664,12 @@ button.hamburger {
|
|||||||
/* Hide thinking sections globally via settings toggle */
|
/* Hide thinking sections globally via settings toggle */
|
||||||
body.hide-thinking .thinking-section { display: none !important; }
|
body.hide-thinking .thinking-section { display: none !important; }
|
||||||
|
|
||||||
|
/* Widen chat area via settings toggle */
|
||||||
|
body.fullwidth-chat .chat-history {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
padding-right: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Thinking process styles — colors follow theme accent */
|
/* Thinking process styles — colors follow theme accent */
|
||||||
.msg .body .stream-content {
|
.msg .body .stream-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -16917,7 +16960,8 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod
|
|||||||
/* Kebab dropdown */
|
/* Kebab dropdown */
|
||||||
.skill-kebab-menu {
|
.skill-kebab-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 100002;
|
/* z-index is set inline via topPortalZ() at open time (#4720); a static
|
||||||
|
value here loses once the modal bring-to-front counter climbs past it. */
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background: var(--panel, var(--bg));
|
background: var(--panel, var(--bg));
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Registry wiring for the config/integration admin tools (#3629).
|
||||||
|
|
||||||
|
manage_endpoints/mcp/webhooks/tokens/settings moved from tool_implementations
|
||||||
|
into agent_tools.admin_tools. These pin the registration + the single
|
||||||
|
owner-threading adapter factory, without touching the DB (the do_* impls
|
||||||
|
themselves are exercised by their own suites).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from src.agent_tools import TOOL_HANDLERS
|
||||||
|
from src.agent_tools.admin_tools import (
|
||||||
|
ADMIN_TOOL_HANDLERS, _owner_adapter,
|
||||||
|
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||||
|
do_manage_tokens, do_manage_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
_NAMES = ["manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "manage_settings"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_registered_in_tool_handlers():
|
||||||
|
for n in _NAMES:
|
||||||
|
assert n in TOOL_HANDLERS, f"{n} missing from TOOL_HANDLERS"
|
||||||
|
assert n in ADMIN_TOOL_HANDLERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_re_exported_from_agent_tools():
|
||||||
|
# Back-compat: importers that used `from src.agent_tools import do_manage_*`
|
||||||
|
# keep working after the move.
|
||||||
|
from src.agent_tools import ( # noqa: F401
|
||||||
|
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||||
|
do_manage_tokens, do_manage_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_adapter_threads_owner_from_ctx():
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def _spy(content, owner):
|
||||||
|
seen["content"] = content
|
||||||
|
seen["owner"] = owner
|
||||||
|
return {"response": "ok", "exit_code": 0}
|
||||||
|
|
||||||
|
handler = _owner_adapter(_spy)
|
||||||
|
res = asyncio.run(handler('{"action":"list"}', {"owner": "alice", "session_id": "s1"}))
|
||||||
|
assert res["exit_code"] == 0
|
||||||
|
assert seen == {"content": '{"action":"list"}', "owner": "alice"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_adapter_defaults_owner_to_none():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def _spy(content, owner):
|
||||||
|
captured["owner"] = owner
|
||||||
|
return {"exit_code": 0}
|
||||||
|
|
||||||
|
asyncio.run(_owner_adapter(_spy)("{}", {})) # ctx without owner
|
||||||
|
assert captured["owner"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_tool_args_lives_in_tool_utils_single_source():
|
||||||
|
# The helper was de-duplicated into tool_utils; admin_tools imports it
|
||||||
|
# from there rather than carrying its own copy.
|
||||||
|
from src.tool_utils import _parse_tool_args
|
||||||
|
from src.agent_tools import admin_tools, document_tools
|
||||||
|
assert admin_tools._parse_tool_args is _parse_tool_args
|
||||||
|
assert document_tools._parse_tool_args is _parse_tool_args
|
||||||
|
assert _parse_tool_args('{"action":"add"}') == {"action": "add"}
|
||||||
|
# body-envelope unwrap still works
|
||||||
|
assert _parse_tool_args('{"body":{"action":"x"}}') == {"action": "x"}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Test that APIKeyManager.save() uses atomic write to prevent data loss."""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, mock_open
|
||||||
|
from src.api_key_manager import APIKeyManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_creates_atomic_tmp_file(tmp_path):
|
||||||
|
"""Verify save() writes to a temp file and replaces atomically."""
|
||||||
|
mgr = APIKeyManager(str(tmp_path))
|
||||||
|
mgr.save("openai", "sk-test")
|
||||||
|
|
||||||
|
# The final file should exist with the correct content
|
||||||
|
assert os.path.exists(mgr.api_keys_file)
|
||||||
|
with open(mgr.api_keys_file, "r", encoding="utf-8") as f:
|
||||||
|
keys = json.load(f)
|
||||||
|
assert "openai" in keys
|
||||||
|
|
||||||
|
# The temp file should NOT remain after successful save
|
||||||
|
tmp_file = mgr.api_keys_file + ".tmp"
|
||||||
|
assert not os.path.exists(tmp_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_preserves_existing_keys_atomically(tmp_path):
|
||||||
|
"""Verify atomic save doesn't corrupt other providers' keys."""
|
||||||
|
mgr = APIKeyManager(str(tmp_path))
|
||||||
|
mgr.save("openai", "sk-openai")
|
||||||
|
mgr.save("anthropic", "sk-anthropic")
|
||||||
|
|
||||||
|
loaded = mgr.load()
|
||||||
|
assert loaded["openai"] == "sk-openai"
|
||||||
|
assert loaded["anthropic"] == "sk-anthropic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_preserves_original_on_write_failure(tmp_path):
|
||||||
|
"""If the temp file write fails, the original keys file must survive intact."""
|
||||||
|
mgr = APIKeyManager(str(tmp_path))
|
||||||
|
mgr.save("openai", "sk-original")
|
||||||
|
|
||||||
|
# Now attempt a save that will fail during json.dump
|
||||||
|
with patch("builtins.open", side_effect=OSError("disk full")):
|
||||||
|
with pytest.raises(OSError, match="disk full"):
|
||||||
|
mgr.save("anthropic", "sk-new")
|
||||||
|
|
||||||
|
# Original file must still be intact with the original key
|
||||||
|
loaded = mgr.load()
|
||||||
|
assert loaded == {"openai": "sk-original"}
|
||||||
|
assert "anthropic" not in loaded
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_cleans_up_tmp_on_failure(tmp_path):
|
||||||
|
"""Temp file should be removed if the write fails."""
|
||||||
|
mgr = APIKeyManager(str(tmp_path))
|
||||||
|
mgr.save("openai", "sk-original")
|
||||||
|
|
||||||
|
tmp_file = mgr.api_keys_file + ".tmp"
|
||||||
|
|
||||||
|
# Force a failure after the temp file is opened
|
||||||
|
original_open = open
|
||||||
|
|
||||||
|
def failing_open(*args, **kwargs):
|
||||||
|
f = original_open(*args, **kwargs)
|
||||||
|
if args and isinstance(args[0], str) and args[0].endswith(".tmp"):
|
||||||
|
# Close the file then raise
|
||||||
|
f.close()
|
||||||
|
raise OSError("simulated write failure")
|
||||||
|
return f
|
||||||
|
|
||||||
|
with patch("builtins.open", side_effect=failing_open):
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
mgr.save("anthropic", "sk-new")
|
||||||
|
|
||||||
|
# Temp file should be cleaned up
|
||||||
|
assert not os.path.exists(tmp_file)
|
||||||
|
|
||||||
|
# Original should be intact
|
||||||
|
loaded = mgr.load()
|
||||||
|
assert loaded == {"openai": "sk-original"}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Regression coverage for durable ``ask_user`` choice cards.
|
||||||
|
|
||||||
|
The live event must arrive after ``tool_output`` so the settled tool trace
|
||||||
|
cannot cover/push away the card. The same payload must be persisted inside
|
||||||
|
``tool_events`` so chat history can reconstruct it after a reload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import src.agent_loop as agent_loop
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _collect(gen):
|
||||||
|
async def _run():
|
||||||
|
return [chunk async for chunk in gen]
|
||||||
|
|
||||||
|
return asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def _events(chunks):
|
||||||
|
events = []
|
||||||
|
for chunk in chunks:
|
||||||
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||||
|
events.append(json.loads(chunk[6:]))
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_user_is_emitted_last_and_persisted(monkeypatch):
|
||||||
|
payload = {
|
||||||
|
"question": "¿Qué proyecto prefieres?",
|
||||||
|
"options": [
|
||||||
|
{"label": "Análisis de reseñas"},
|
||||||
|
{"label": "Clasificación temática"},
|
||||||
|
],
|
||||||
|
"multi": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default, raising=False)
|
||||||
|
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None, raising=False)
|
||||||
|
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10, raising=False)
|
||||||
|
|
||||||
|
async def fake_stream(_candidates, messages, **kwargs):
|
||||||
|
call = {"name": "ask_user", "arguments": json.dumps(payload, ensure_ascii=False)}
|
||||||
|
yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
async def fake_execute(block, *args, **kwargs):
|
||||||
|
parsed = json.loads(block.content)
|
||||||
|
return (
|
||||||
|
"ask_user",
|
||||||
|
{
|
||||||
|
"ask_user": parsed,
|
||||||
|
"output": "Awaiting their selection.",
|
||||||
|
"exit_code": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream, raising=False)
|
||||||
|
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute, raising=False)
|
||||||
|
|
||||||
|
chunks = _collect(
|
||||||
|
agent_loop.stream_agent_loop(
|
||||||
|
"https://api.openai.com/v1",
|
||||||
|
"gpt-4o",
|
||||||
|
[{"role": "user", "content": "Ayúdame a elegir un proyecto."}],
|
||||||
|
relevant_tools={"ask_user"},
|
||||||
|
_is_teacher_run=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
events = _events(chunks)
|
||||||
|
|
||||||
|
tool_output_index = next(i for i, event in enumerate(events) if event.get("type") == "tool_output")
|
||||||
|
ask_user_index = next(i for i, event in enumerate(events) if event.get("type") == "ask_user")
|
||||||
|
assert tool_output_index < ask_user_index
|
||||||
|
|
||||||
|
tool_output = events[tool_output_index]
|
||||||
|
assert tool_output["ask_user"] == payload
|
||||||
|
assert "¿Qué proyecto prefieres?" in tool_output["command"]
|
||||||
|
assert "\\u00" not in tool_output["command"]
|
||||||
|
|
||||||
|
metrics = next(event["data"] for event in events if event.get("type") == "metrics")
|
||||||
|
assert metrics["tool_events"][0]["ask_user"] == payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_uses_one_renderer_for_live_and_restored_cards():
|
||||||
|
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
|
||||||
|
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "chatRenderer.renderAskUserCard(json.data || {})" in chat
|
||||||
|
assert "export function renderAskUserCard" in renderer
|
||||||
|
assert "renderAskUserCard(pendingAskUser" in renderer
|
||||||
|
assert "if (role === 'user') removeAskUserCards(box)" in renderer
|
||||||
@@ -85,6 +85,19 @@ def test_serializer_round_trips_structured_args():
|
|||||||
assert json.loads(block.content) == args
|
assert json.loads(block.content) == args
|
||||||
|
|
||||||
|
|
||||||
|
def test_serializer_keeps_unicode_readable_for_tool_trace():
|
||||||
|
from src.tool_schemas import function_call_to_tool_block
|
||||||
|
|
||||||
|
args = {
|
||||||
|
"question": "¿Qué proyecto prefieres?",
|
||||||
|
"options": [{"label": "Reseñas"}, {"label": "Clasificación"}],
|
||||||
|
}
|
||||||
|
block = function_call_to_tool_block("ask_user", json.dumps(args, ensure_ascii=False))
|
||||||
|
assert "¿Qué proyecto prefieres?" in block.content
|
||||||
|
assert "Reseñas" in block.content
|
||||||
|
assert "\\u00" not in block.content
|
||||||
|
|
||||||
|
|
||||||
def test_registered_everywhere():
|
def test_registered_everywhere():
|
||||||
# TOOL_TAGS gate (serializer rejects unknown tools)
|
# TOOL_TAGS gate (serializer rejects unknown tools)
|
||||||
assert "ask_user" in TOOL_TAGS
|
assert "ask_user" in TOOL_TAGS
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Regression tests for auth-disabled document access (PR #4623).
|
||||||
|
|
||||||
|
Validates that the _auth_disabled() bypass in _verify_doc_owner and
|
||||||
|
list_documents restores single-user / no-auth mode WITHOUT weakening the
|
||||||
|
authenticated path. Three pinned directions:
|
||||||
|
|
||||||
|
1. AUTH_DISABLED + None user -> list_documents + doc read SUCCEEDS
|
||||||
|
(the bug being fixed).
|
||||||
|
2. AUTH_ENABLED + None user -> still 403.
|
||||||
|
3. AUTH_ENABLED + wrong owner -> _verify_doc_owner still raises 404/403.
|
||||||
|
|
||||||
|
Route handlers are called directly (same pattern as
|
||||||
|
test_document_session_owner_scope.py) so coverage lands on the real
|
||||||
|
closures without spinning up middleware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from tests.helpers.import_state import clear_fake_database_modules
|
||||||
|
|
||||||
|
clear_fake_database_modules()
|
||||||
|
|
||||||
|
import core.database as cdb
|
||||||
|
import routes.document_routes as droutes
|
||||||
|
from core.database import Document
|
||||||
|
from core.database import Session as DbSession
|
||||||
|
from routes.document_helpers import _verify_doc_owner, _owner_session_filter
|
||||||
|
|
||||||
|
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_ENGINE = create_engine(
|
||||||
|
f"sqlite:///{_TMPDB.name}",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=NullPool,
|
||||||
|
)
|
||||||
|
cdb.Base.metadata.create_all(_ENGINE)
|
||||||
|
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
|
||||||
|
def _req(user=None):
|
||||||
|
"""Build a minimal fake Request whose state.current_user returns *user*."""
|
||||||
|
return SimpleNamespace(state=SimpleNamespace(current_user=user))
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(method, path):
|
||||||
|
"""Resolve a route endpoint from the document router."""
|
||||||
|
router = droutes.setup_document_routes(MagicMock(), None)
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", None) == path and method in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise RuntimeError(f"{method} {path} not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_test_db():
|
||||||
|
previous = droutes.SessionLocal
|
||||||
|
droutes.SessionLocal = _TS
|
||||||
|
return previous
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(owner="alice"):
|
||||||
|
"""Create one session + one owned document. Returns (session_id, doc_id)."""
|
||||||
|
session_id = f"{owner}-" + uuid.uuid4().hex[:8]
|
||||||
|
doc_id = str(uuid.uuid4())
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
db.add(DbSession(
|
||||||
|
id=session_id, owner=owner, name=owner,
|
||||||
|
model="m", endpoint_url="http://x",
|
||||||
|
))
|
||||||
|
db.add(Document(
|
||||||
|
id=doc_id,
|
||||||
|
session_id=session_id,
|
||||||
|
title=f"{owner} doc",
|
||||||
|
language="markdown",
|
||||||
|
current_content=f"{owner} body",
|
||||||
|
version_count=1,
|
||||||
|
is_active=True,
|
||||||
|
owner=owner,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
return session_id, doc_id
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ 1. auth DISABLED +
|
||||||
|
# None user -> succeeds
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_documents_allows_none_user_when_auth_disabled(monkeypatch):
|
||||||
|
"""AUTH_ENABLED=false + user=None must NOT raise 403 on list_documents."""
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
previous = _bind_test_db()
|
||||||
|
try:
|
||||||
|
list_docs = _endpoint("GET", "/api/documents/{session_id}")
|
||||||
|
session_id, doc_id = _seed()
|
||||||
|
|
||||||
|
# Must succeed — this is the bug fix.
|
||||||
|
rows = await list_docs(_req(None), session_id)
|
||||||
|
ids = [row["id"] for row in rows]
|
||||||
|
assert doc_id in ids, "own doc must be visible in auth-disabled mode"
|
||||||
|
finally:
|
||||||
|
droutes.SessionLocal = previous
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_document_allows_none_user_when_auth_disabled(monkeypatch):
|
||||||
|
"""AUTH_ENABLED=false + user=None must NOT raise 403 on get_document."""
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
previous = _bind_test_db()
|
||||||
|
try:
|
||||||
|
get_doc = _endpoint("GET", "/api/document/{doc_id}")
|
||||||
|
_session_id, doc_id = _seed()
|
||||||
|
|
||||||
|
# Must succeed — _verify_doc_owner bypasses when auth is disabled.
|
||||||
|
result = await get_doc(_req(None), doc_id)
|
||||||
|
assert result["id"] == doc_id
|
||||||
|
finally:
|
||||||
|
droutes.SessionLocal = previous
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_doc_owner_allows_none_user_when_auth_disabled(monkeypatch):
|
||||||
|
"""_verify_doc_owner with user=None + AUTH_ENABLED=false must pass."""
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
_session_id, doc_id = _seed()
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||||
|
# Must NOT raise — the bypass allows single-user access.
|
||||||
|
_verify_doc_owner(db, doc, None)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_session_filter_noops_for_none_user_when_auth_disabled(monkeypatch):
|
||||||
|
"""_owner_session_filter with user=None + AUTH_ENABLED=false returns query unchanged."""
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
_session_id, doc_id = _seed()
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
q = db.query(Document).filter(Document.id == doc_id)
|
||||||
|
result = _owner_session_filter(q, None)
|
||||||
|
# Filter was a no-op; document is still reachable.
|
||||||
|
assert result.first().id == doc_id
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ 2. auth ENABLED +
|
||||||
|
# None user -> 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_documents_rejects_none_user_when_auth_enabled(monkeypatch):
|
||||||
|
"""AUTH_ENABLED=true (default) + user=None must raise 403."""
|
||||||
|
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||||
|
previous = _bind_test_db()
|
||||||
|
try:
|
||||||
|
list_docs = _endpoint("GET", "/api/documents/{session_id}")
|
||||||
|
session_id, _doc_id = _seed()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await list_docs(_req(None), session_id)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
finally:
|
||||||
|
droutes.SessionLocal = previous
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_document_rejects_none_user_when_auth_enabled(monkeypatch):
|
||||||
|
"""AUTH_ENABLED=true (default) + user=None must raise 403 via _verify_doc_owner."""
|
||||||
|
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||||
|
previous = _bind_test_db()
|
||||||
|
try:
|
||||||
|
get_doc = _endpoint("GET", "/api/document/{doc_id}")
|
||||||
|
_session_id, doc_id = _seed()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await get_doc(_req(None), doc_id)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
finally:
|
||||||
|
droutes.SessionLocal = previous
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_doc_owner_rejects_none_user_when_auth_enabled(monkeypatch):
|
||||||
|
"""_verify_doc_owner with user=None + AUTH_ENABLED=true must raise 403."""
|
||||||
|
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||||
|
_session_id, doc_id = _seed()
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_verify_doc_owner(db, doc, None)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------ 3. auth ENABLED + wrong owner ->
|
||||||
|
# _verify_doc_owner raises 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_doc_owner_rejects_wrong_owner_when_auth_enabled(monkeypatch):
|
||||||
|
"""_verify_doc_owner with a mismatched owner must raise 404 (not 403).
|
||||||
|
|
||||||
|
This confirms the authenticated path is untouched by the no-auth bypass."""
|
||||||
|
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||||
|
session_id, doc_id = _seed(owner="alice")
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_verify_doc_owner(db, doc, "bob") # bob != alice
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_document_rejects_wrong_owner(monkeypatch):
|
||||||
|
"""GET /api/document/{doc_id} with wrong authenticated user -> 404."""
|
||||||
|
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||||
|
previous = _bind_test_db()
|
||||||
|
try:
|
||||||
|
get_doc = _endpoint("GET", "/api/document/{doc_id}")
|
||||||
|
_session_id, doc_id = _seed(owner="alice")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await get_doc(_req("bob"), doc_id)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
finally:
|
||||||
|
droutes.SessionLocal = previous
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_documents_hides_wrong_owner_docs(monkeypatch):
|
||||||
|
"""list_documents for alice must not show bob's documents."""
|
||||||
|
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||||
|
previous = _bind_test_db()
|
||||||
|
try:
|
||||||
|
list_docs = _endpoint("GET", "/api/documents/{session_id}")
|
||||||
|
|
||||||
|
# Seed alice's session with a doc
|
||||||
|
alice_session, alice_doc = _seed(owner="alice")
|
||||||
|
# Create bob's session+doc in the SAME session so ownership filter kicks in
|
||||||
|
bob_session = "bob-" + uuid.uuid4().hex[:8]
|
||||||
|
bob_doc = str(uuid.uuid4())
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
db.add(DbSession(id=bob_session, owner="bob", name="bob", model="m", endpoint_url="http://x"))
|
||||||
|
db.add(Document(
|
||||||
|
id=bob_doc, session_id=alice_session, # same session!
|
||||||
|
title="bob doc", language="markdown", current_content="bob body",
|
||||||
|
version_count=1, is_active=True, owner="bob",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
rows = await list_docs(_req("alice"), alice_session)
|
||||||
|
ids = [row["id"] for row in rows]
|
||||||
|
assert alice_doc in ids
|
||||||
|
assert bob_doc not in ids, "wrong-owner docs must be hidden"
|
||||||
|
finally:
|
||||||
|
droutes.SessionLocal = previous
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
r"""DOM/CSS-injection regression for calendar background-image URL escaping.
|
||||||
|
|
||||||
|
CodeQL `js/incomplete-sanitization` (#463 calendar.js:416, #464 calendar.js:1263)
|
||||||
|
flagged event-background CSS that escaped `'` -> `\'` without first escaping
|
||||||
|
backslashes. A `bg:`-color value (settable per event, and CalDAV-syncable, so
|
||||||
|
untrusted) ending in or containing a backslash can then consume the closing
|
||||||
|
quote of `url('...')` and break out of the CSS string.
|
||||||
|
|
||||||
|
The fix is a single canonical escaper, `_cssUrlEscape`, in calendar/utils.js,
|
||||||
|
used by both inline sinks and by `_calBgCss` (which had the same incomplete
|
||||||
|
escaping). These tests pin the escaper: backslashes are doubled FIRST, then
|
||||||
|
quotes, so no input can terminate the `url('...')` string early.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_UTILS = (_REPO / "static" / "js" / "calendar" / "utils.js").as_posix()
|
||||||
|
_CALENDAR_JS = _REPO / "static" / "js" / "calendar.js"
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(js: str) -> str:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return proc.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cssurlescape_doubles_backslashes_before_quotes():
|
||||||
|
js = textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
const {{ _cssUrlEscape }} = await import('{_UTILS}');
|
||||||
|
console.log(JSON.stringify({{
|
||||||
|
backslash: _cssUrlEscape('a\\\\b'),
|
||||||
|
trailing: _cssUrlEscape('img\\\\'),
|
||||||
|
quote: _cssUrlEscape("a'b"),
|
||||||
|
dquote: _cssUrlEscape('a"b'),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
out = json.loads(_run(js))
|
||||||
|
# one backslash -> two; the escape for "'" is not itself re-escaped
|
||||||
|
assert out["backslash"] == r"a\\b"
|
||||||
|
assert out["trailing"] == "img\\\\" # 'img\' -> 'img\\'
|
||||||
|
assert out["quote"] == r"a\'b"
|
||||||
|
assert out["dquote"] == "a%22b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backslash_breakout_payload_cannot_close_the_url_string():
|
||||||
|
# Without the backslash-first escape, "x\" would render url('x\') and the
|
||||||
|
# trailing backslash escapes the closing quote -> breakout. After the fix the
|
||||||
|
# backslash is doubled, so the quote we add still terminates the string.
|
||||||
|
js = textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
const {{ _cssUrlEscape, _calBgCss }} = await import('{_UTILS}');
|
||||||
|
const payload = 'x\\\\'; // a string ending in one backslash
|
||||||
|
console.log(JSON.stringify({{
|
||||||
|
esc: _cssUrlEscape(payload),
|
||||||
|
css: _calBgCss('bg:' + payload, 'var(--accent)'),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
out = json.loads(_run(js))
|
||||||
|
assert out["esc"] == "x\\\\" # doubled backslash
|
||||||
|
# The rendered declaration keeps the backslash doubled inside url('...').
|
||||||
|
assert "url('x\\\\')" in out["css"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_calbgcss_escapes_quote_breakout():
|
||||||
|
js = textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
const {{ _calBgCss }} = await import('{_UTILS}');
|
||||||
|
console.log(JSON.stringify(_calBgCss("bg:a'); X{{}}//", 'var(--accent)')));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
css = json.loads(_run(js))
|
||||||
|
# the injected single quote is escaped, so the url() string is not closed early
|
||||||
|
assert r"\'" in css
|
||||||
|
assert "url('a\\'); X{}//')" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_calendar_url_interpolation_is_escaped():
|
||||||
|
# Whole-file invariant: every CSS `url('${...}')` built in calendar.js must
|
||||||
|
# route its (CalDAV-syncable, untrusted) value through `_cssUrlEscape`. This
|
||||||
|
# is the guard that catches a *newly added* bg-image sink the centralization
|
||||||
|
# forgot - the failure mode that left calendar.js:2856 (edit-form color
|
||||||
|
# swatch) and :2953 (custom-dot preview) raw before this change.
|
||||||
|
src = _CALENDAR_JS.read_text(encoding="utf-8")
|
||||||
|
interps = re.findall(r"url\('\$\{([^}]*)\}'\)", src)
|
||||||
|
assert interps, "expected at least one url('${...}') interpolation in calendar.js"
|
||||||
|
unescaped = [expr for expr in interps if "_cssUrlEscape(" not in expr]
|
||||||
|
assert not unescaped, (
|
||||||
|
"bg-image url() interpolation(s) not routed through _cssUrlEscape: "
|
||||||
|
+ ", ".join(repr(e) for e in unescaped)
|
||||||
|
)
|
||||||
@@ -86,7 +86,8 @@ def test_default_settings_registers_hard_max_key():
|
|||||||
def test_alias_map_registers_friendly_names():
|
def test_alias_map_registers_friendly_names():
|
||||||
"""`manage_settings` should accept 'hard max' and friends."""
|
"""`manage_settings` should accept 'hard max' and friends."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
src = Path("src/tool_implementations.py").read_text()
|
# manage_settings (and its alias map) moved to agent_tools/admin_tools.py in #3629.
|
||||||
|
src = Path("src/agent_tools/admin_tools.py").read_text()
|
||||||
assert '"hard max": "agent_input_token_hard_max"' in src
|
assert '"hard max": "agent_input_token_hard_max"' in src
|
||||||
assert '"token budget cap": "agent_input_token_hard_max"' in src
|
assert '"token budget cap": "agent_input_token_hard_max"' in src
|
||||||
assert '"input budget cap": "agent_input_token_hard_max"' in src
|
assert '"input budget cap": "agent_input_token_hard_max"' in src
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Behavioral tests for Cookbook port parsing / picking (#4507 follow-up).
|
||||||
|
|
||||||
|
Driven through `node --input-type=module` (same approach as the other
|
||||||
|
*_js.py tests); skips when `node` is not installed.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_HELPER = _REPO / "static" / "js" / "cookbookPorts.js"
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _run(expr):
|
||||||
|
js = (
|
||||||
|
f"import {{ portOf, nextFreePort }} from '{_HELPER.as_posix()}';"
|
||||||
|
f"console.log(JSON.stringify({expr}));"
|
||||||
|
)
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_port_of_handles_all_forms():
|
||||||
|
assert _run("portOf('vllm serve m --host 0.0.0.0 --port 8000')") == "8000"
|
||||||
|
assert _run("portOf('x --port=8001')") == "8001"
|
||||||
|
assert _run("portOf('llama-server -p 8002')") == "8002"
|
||||||
|
assert _run("portOf('llama-server -p=8003')") == "8003"
|
||||||
|
assert _run("portOf('serve with no port flag')") == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_next_free_port_skips_taken_including_eq_and_short_flag():
|
||||||
|
# a --port= serve and a -p serve are both 'taken'; picker skips them
|
||||||
|
taken = "[portOf('a --port=8000'), portOf('b -p 8001')]"
|
||||||
|
assert _run(f"nextFreePort({taken})") == "8002"
|
||||||
|
assert _run("nextFreePort([])") == "8000"
|
||||||
|
assert _run("nextFreePort(['8000', '8002'])") == "8001"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_clash_outcome_same_port_flagged_different_ignored():
|
||||||
|
# the guard's predicate is portOf(cmd) === target
|
||||||
|
assert _run("portOf('m --port 8000') === '8000'") is True
|
||||||
|
assert _run("portOf('m --port 8001') === '8000'") is False
|
||||||
@@ -53,6 +53,8 @@ with preserve_import_state("core.database", "src.database", "core.session_manage
|
|||||||
_resolve_probe_key,
|
_resolve_probe_key,
|
||||||
_classify_endpoint,
|
_classify_endpoint,
|
||||||
_rewrite_loopback_for_docker,
|
_rewrite_loopback_for_docker,
|
||||||
|
_openai_model_ids,
|
||||||
|
_ollama_model_names,
|
||||||
_PROVIDER_CURATED,
|
_PROVIDER_CURATED,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +76,33 @@ def _resp(status, *, json=None, headers=None, url="https://api.example.com/v1/mo
|
|||||||
return httpx.Response(status, **kwargs)
|
return httpx.Response(status, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _openai_model_ids / _ollama_model_names: parsing helpers ──
|
||||||
|
|
||||||
|
class TestModelListHelpers:
|
||||||
|
@pytest.mark.parametrize("data,expected", [
|
||||||
|
({"data": [{"id": "gpt-4o"}, {"id": "gpt-4o-mini"}]}, ["gpt-4o", "gpt-4o-mini"]),
|
||||||
|
({"data": [{"id": None}, {"id": 123}, {"id": "gpt-4o"}]}, ["gpt-4o"]), # non-string ids dropped
|
||||||
|
({"data": ["x", {"id": "ok"}]}, ["ok"]), # non-dict entries dropped
|
||||||
|
({"data": []}, []),
|
||||||
|
({"data": "oops"}, []), # non-list "data"
|
||||||
|
([], []), ("nope", []), (None, []), (123, []), # non-dict body
|
||||||
|
])
|
||||||
|
def test_openai_model_ids(self, data, expected):
|
||||||
|
assert _openai_model_ids(data) == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("data,expected", [
|
||||||
|
({"models": [{"name": "llama3:8b"}, {"model": "qwen3:4b"}]}, ["llama3:8b", "qwen3:4b"]),
|
||||||
|
({"models": [{"name": "a", "model": "b"}]}, ["a"]), # name precedence over model
|
||||||
|
({"models": [{"name": 123}, {"model": None}, {"name": "ok"}]}, ["ok"]), # non-string values dropped
|
||||||
|
({"models": ["x", {"name": "ok"}]}, ["ok"]), # non-dict entries dropped
|
||||||
|
({"models": []}, []),
|
||||||
|
({"models": "oops"}, []),
|
||||||
|
([], []), (None, []), (42, []), # non-dict body
|
||||||
|
])
|
||||||
|
def test_ollama_model_names(self, data, expected):
|
||||||
|
assert _ollama_model_names(data) == expected
|
||||||
|
|
||||||
|
|
||||||
# ── _probe_endpoint: model-list parsing ──
|
# ── _probe_endpoint: model-list parsing ──
|
||||||
|
|
||||||
class TestProbeEndpointParsing:
|
class TestProbeEndpointParsing:
|
||||||
@@ -121,6 +150,43 @@ class TestProbeEndpointParsing:
|
|||||||
)
|
)
|
||||||
assert _probe_endpoint("https://api.example.com/v1") == []
|
assert _probe_endpoint("https://api.example.com/v1") == []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body", [[], "invalid", 123, True])
|
||||||
|
def test_non_dict_json_body_degrades_to_empty(self, monkeypatch, caplog, body):
|
||||||
|
# HTTP 200 with valid-but-non-dict JSON must not crash the probe with an
|
||||||
|
# AttributeError (data.get(...) on a list/str/int); it should fall through
|
||||||
|
# to the empty/curated path. caplog gives this test teeth: pre-fix the
|
||||||
|
# swallowed AttributeError logs "Failed to probe"; post-fix it does not.
|
||||||
|
_patch_resolve(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_routes.httpx, "get",
|
||||||
|
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(200, json=body),
|
||||||
|
)
|
||||||
|
with caplog.at_level("WARNING", logger="routes.model_routes"):
|
||||||
|
assert _probe_endpoint("https://api.example.com/v1") == []
|
||||||
|
assert "Failed to probe" not in caplog.text
|
||||||
|
|
||||||
|
def test_skips_non_string_model_ids(self, monkeypatch):
|
||||||
|
# A non-compliant upstream returns int/None IDs alongside a valid one.
|
||||||
|
# The probe must not crash on .lower()/.startswith and must still surface
|
||||||
|
# the valid string model.
|
||||||
|
_patch_resolve(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_routes.httpx, "get",
|
||||||
|
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(
|
||||||
|
200, json={"data": [{"id": None}, {"id": 123}, {"id": "gpt-4o"}]}),
|
||||||
|
)
|
||||||
|
assert _probe_endpoint("https://api.example.com/v1", "key") == ["gpt-4o"]
|
||||||
|
|
||||||
|
def test_all_non_string_ids_returns_empty(self, monkeypatch):
|
||||||
|
# Every id is non-string -> empty result, no exception, no curated leak.
|
||||||
|
_patch_resolve(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_routes.httpx, "get",
|
||||||
|
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(
|
||||||
|
200, json={"data": [{"id": 123}, {"id": None}]}),
|
||||||
|
)
|
||||||
|
assert _probe_endpoint("https://api.example.com/v1") == []
|
||||||
|
|
||||||
def test_chatgpt_subscription_probe_uses_discovery_only(self, monkeypatch):
|
def test_chatgpt_subscription_probe_uses_discovery_only(self, monkeypatch):
|
||||||
_patch_resolve(monkeypatch)
|
_patch_resolve(monkeypatch)
|
||||||
calls = []
|
calls = []
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from services.hwfit.fit import rank_models
|
||||||
|
from services.hwfit.models import get_models, is_prequantized
|
||||||
|
|
||||||
|
|
||||||
|
def _8gb_vram_system():
|
||||||
|
return {
|
||||||
|
"has_gpu": True,
|
||||||
|
"backend": "cuda",
|
||||||
|
"gpu_name": "NVIDIA GeForce RTX 4060",
|
||||||
|
"gpu_vram_gb": 8.0,
|
||||||
|
"gpu_count": 1,
|
||||||
|
"available_ram_gb": 32.0,
|
||||||
|
"total_ram_gb": 32.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemma4_12b_in_catalog():
|
||||||
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
|
assert "google/gemma-4-12B-it" in catalog, "gemma-4-12B-it missing from catalog"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemma4_12b_has_gguf_source():
|
||||||
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
|
entry = catalog["google/gemma-4-12B-it"]
|
||||||
|
assert entry.get("gguf_sources"), "gemma-4-12B-it has no gguf_sources"
|
||||||
|
repos = [s["repo"] for s in entry["gguf_sources"]]
|
||||||
|
assert "unsloth/gemma-4-12B-it-GGUF" in repos
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemma4_12b_rank_models_returns_it_for_8gb_vram():
|
||||||
|
results = rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20)
|
||||||
|
names = [r["name"] for r in results]
|
||||||
|
assert "google/gemma-4-12B-it" in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemma4_12b_qat_entries_in_catalog():
|
||||||
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
|
assert "google/gemma-4-12B-it-qat-int4" in catalog
|
||||||
|
assert "google/gemma-4-12B-it-qat-int8" in catalog
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemma4_12b_qat_entries_are_prequantized():
|
||||||
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
|
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int4"])
|
||||||
|
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int8"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemma4_12b_qat_entries_have_no_gguf():
|
||||||
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
|
assert catalog["google/gemma-4-12B-it-qat-int4"]["gguf_sources"] == []
|
||||||
|
assert catalog["google/gemma-4-12B-it-qat-int8"]["gguf_sources"] == []
|
||||||
@@ -72,3 +72,50 @@ def test_gguf_alternate_still_recommended_on_windows():
|
|||||||
still appear on Windows even though the AWQ variant is hidden."""
|
still appear on Windows even though the AWQ variant is hidden."""
|
||||||
names = {r["name"] for r in rank_models(_windows_system(), limit=900)}
|
names = {r["name"] for r in rank_models(_windows_system(), limit=900)}
|
||||||
assert "Qwen/Qwen2.5-3B-Instruct" in names
|
assert "Qwen/Qwen2.5-3B-Instruct" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_windows_probe_uses_encoded_command(monkeypatch):
|
||||||
|
"""Remote Windows hwfit must not use nested -Command quoting over SSH."""
|
||||||
|
from services.hwfit import hardware
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(hardware, "_remote_host", "user@winpc")
|
||||||
|
monkeypatch.setattr(hardware, "_remote_port", None)
|
||||||
|
|
||||||
|
def fake_run(cmd):
|
||||||
|
calls.append(cmd)
|
||||||
|
if isinstance(cmd, str) and "EncodedCommand" in cmd:
|
||||||
|
return (
|
||||||
|
'{"ram_gb":64,"avail_gb":32,"cpu_name":"Test CPU",'
|
||||||
|
'"cpu_cores":8,"arch":64}'
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(hardware, "_run", fake_run)
|
||||||
|
result = hardware._detect_windows()
|
||||||
|
assert result is not None
|
||||||
|
assert result["total_ram_gb"] == 64
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert "EncodedCommand" in calls[0]
|
||||||
|
assert '-Command "' not in calls[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_remote_platform_detects_windows(monkeypatch):
|
||||||
|
from services.hwfit import hardware
|
||||||
|
|
||||||
|
monkeypatch.setattr(hardware, "_run", lambda cmd: "Windows_NT\n")
|
||||||
|
assert hardware._probe_remote_platform() == "windows"
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_remote_platform_detects_darwin(monkeypatch):
|
||||||
|
from services.hwfit import hardware
|
||||||
|
|
||||||
|
def fake_run(cmd):
|
||||||
|
if cmd == "echo %OS%":
|
||||||
|
return "%OS%"
|
||||||
|
if cmd == ["uname", "-s"]:
|
||||||
|
return "Darwin"
|
||||||
|
raise AssertionError(f"unexpected probe cmd: {cmd!r}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(hardware, "_run", fake_run)
|
||||||
|
assert hardware._probe_remote_platform() == "linux"
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Regression test for #3993 — live chat leaves executed tool fences visible.
|
||||||
|
|
||||||
|
The backend strips every fenced tool block (``src/tool_parsing.py`` builds its
|
||||||
|
regex from the full ``TOOL_TAGS`` set), so a reloaded session renders cleanly.
|
||||||
|
The live frontend path uses its own regex, ``EXEC_FENCE_RE`` in
|
||||||
|
``static/js/chatRenderer.js``.
|
||||||
|
|
||||||
|
Originally that regex came from a hand-maintained subset, so any executable tool
|
||||||
|
not in it — and every *future* tool added to ``TOOL_TAGS`` — left its executed
|
||||||
|
fence lingering as a raw code block in the live bubble until reload. The fix
|
||||||
|
makes ``TOOL_TAGS`` the single source: ``chatRenderer.js`` no longer hard-codes a
|
||||||
|
tool list at all. It fetches the backend's authoritative set once from
|
||||||
|
``GET /api/tools`` (which serves ``sorted(TOOL_TAGS)``) and builds
|
||||||
|
``EXEC_FENCE_RE`` from it at load, minus ``bash``/``python`` (legitimate code
|
||||||
|
examples a user may have asked the model to show). There is no second list to
|
||||||
|
drift.
|
||||||
|
|
||||||
|
``chatRenderer.js`` pulls browser globals and can't be imported under node, so
|
||||||
|
the behavioral tests exercise an equivalent Python regex built straight from the
|
||||||
|
backend ``TOOL_TAGS`` — the same source the live regex now derives from — and
|
||||||
|
source-level guards assert the frontend keeps no hard-coded list.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SRC = Path("static/js/chatRenderer.js")
|
||||||
|
_TOOLS_SRC = Path("src/agent_tools/__init__.py")
|
||||||
|
_ROUTES_SRC = Path("routes/model_routes.py")
|
||||||
|
|
||||||
|
# Deliberately NOT stripped: legitimate code-example languages, not tool
|
||||||
|
# invocations. Must match the carve-out in chatRenderer.js.
|
||||||
|
_NON_STRIPPED = {"bash", "python"}
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_tags() -> set[str]:
|
||||||
|
"""Extract the backend TOOL_TAGS set from src/agent_tools/__init__.py (source-level)."""
|
||||||
|
source = _TOOLS_SRC.read_text(encoding="utf-8")
|
||||||
|
m = re.search(r"TOOL_TAGS\s*=\s*\{(?P<body>.*?)\}", source, re.DOTALL)
|
||||||
|
assert m, "TOOL_TAGS literal not found in src/agent_tools/__init__.py"
|
||||||
|
return set(re.findall(r'"([a-z_]+)"', m.group("body")))
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_fence_regex() -> re.Pattern:
|
||||||
|
"""Rebuild EXEC_FENCE_RE's behavior from the same source the live regex now
|
||||||
|
derives from: the backend TOOL_TAGS (served via /api/tools) minus bash/python."""
|
||||||
|
tags = _tool_tags() - _NON_STRIPPED
|
||||||
|
assert tags, "TOOL_TAGS is empty"
|
||||||
|
return re.compile(r"```(?:" + "|".join(sorted(tags)) + r")\s*\n[\s\S]*?```", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_executed_email_tool_fences():
|
||||||
|
rx = _exec_fence_regex()
|
||||||
|
# The exact shape the reporter observed lingering in the live bubble.
|
||||||
|
text = 'Here are emails\n\n```list_emails\n{"max_results":10}\n```'
|
||||||
|
assert rx.sub("", text).strip() == "Here are emails"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_every_named_email_tool_fence():
|
||||||
|
rx = _exec_fence_regex()
|
||||||
|
email_tools = [
|
||||||
|
"list_email_accounts", "send_email", "list_emails", "read_email",
|
||||||
|
"reply_to_email", "bulk_email", "archive_email", "delete_email",
|
||||||
|
"mark_email_read",
|
||||||
|
]
|
||||||
|
for tool in email_tools:
|
||||||
|
fence = f"```{tool}\n{{}}\n```"
|
||||||
|
assert rx.sub("", fence).strip() == "", f"{tool} fence not stripped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preserves_existing_web_search_stripping():
|
||||||
|
rx = _exec_fence_regex()
|
||||||
|
fence = '```web_search\n{"q":"x"}\n```'
|
||||||
|
assert rx.sub("", fence).strip() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_strip_bash_or_python_code_examples():
|
||||||
|
"""bash/python fences are deliberately excluded — they are legitimate code
|
||||||
|
examples a user may have asked the model to show, not tool invocations."""
|
||||||
|
rx = _exec_fence_regex()
|
||||||
|
for lang in sorted(_NON_STRIPPED):
|
||||||
|
example = f"```{lang}\nls -la\n```"
|
||||||
|
assert rx.sub("", example) == example, f"{lang} example wrongly stripped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_keeps_no_hardcoded_tool_list():
|
||||||
|
"""Root-cause guard for #3993: chatRenderer.js must NOT reintroduce a
|
||||||
|
hand-maintained tool list. A hard-coded mirror of TOOL_TAGS silently drifts
|
||||||
|
when a new tool is added — leaving its executed fence in the live bubble
|
||||||
|
until reload. The live regex must instead be built from the backend's
|
||||||
|
authoritative set fetched at runtime."""
|
||||||
|
source = _SRC.read_text(encoding="utf-8")
|
||||||
|
assert "EXEC_TOOL_TAGS" not in source, (
|
||||||
|
"chatRenderer.js reintroduced a hard-coded EXEC_TOOL_TAGS list; the "
|
||||||
|
"live-strip tags must come from GET /api/tools so TOOL_TAGS stays the "
|
||||||
|
"single source (#3993)."
|
||||||
|
)
|
||||||
|
assert "/api/tools" in source, (
|
||||||
|
"chatRenderer.js must fetch the tool set from /api/tools to build "
|
||||||
|
"EXEC_FENCE_RE."
|
||||||
|
)
|
||||||
|
# The bash/python carve-out must survive the move to the runtime list.
|
||||||
|
m = re.search(r"EXEC_FENCE_NON_TOOL\s*=\s*new Set\(\[(?P<body>.*?)\]\)", source, re.DOTALL)
|
||||||
|
assert m, "bash/python carve-out (EXEC_FENCE_NON_TOOL) not found in chatRenderer.js"
|
||||||
|
carve_out = set(re.findall(r"['\"]([a-z_]+)['\"]", m.group("body")))
|
||||||
|
assert carve_out == _NON_STRIPPED, (
|
||||||
|
f"EXEC_FENCE_NON_TOOL must carve out exactly {sorted(_NON_STRIPPED)}, "
|
||||||
|
f"got {sorted(carve_out)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_tools_endpoint_serves_full_tool_tags():
|
||||||
|
"""The frontend's single source is GET /api/tools. Guard that the endpoint
|
||||||
|
serves the complete TOOL_TAGS set (sorted) — if it ever served a subset, the
|
||||||
|
live-strip list would silently shrink with no second list to catch it."""
|
||||||
|
source = _ROUTES_SRC.read_text(encoding="utf-8")
|
||||||
|
assert re.search(r"for\s+tag\s+in\s+sorted\(\s*TOOL_TAGS\s*\)", source), (
|
||||||
|
"GET /api/tools must iterate sorted(TOOL_TAGS) so the frontend's "
|
||||||
|
"EXEC_FENCE_RE covers every executable tool (#3993)."
|
||||||
|
)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Tests for llama.cpp (llama-server) local discovery: the default scan list
|
||||||
|
includes llama-server's port 8080, and `_fingerprint_provider` identifies a
|
||||||
|
llama-server via its native ``/props`` endpoint without misfiring on LM Studio,
|
||||||
|
Ollama, or plain OpenAI-compatible servers.
|
||||||
|
|
||||||
|
Companion to test_lmstudio_discovery.py; the llama.cpp fingerprint is checked
|
||||||
|
*after* the LM Studio one, so LM Studio still wins when both could match.
|
||||||
|
"""
|
||||||
|
from src.model_discovery import ModelDiscovery
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, payload, ok=True):
|
||||||
|
self._payload = payload
|
||||||
|
self.is_success = ok
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# discover_models — scan list includes 8080 (llama-server default)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestLlamaCppScanPort:
|
||||||
|
def test_discover_models_scans_port_8080(self, monkeypatch):
|
||||||
|
"""llama-server's default port 8080 must be among the scan targets."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
scanned_ports = []
|
||||||
|
|
||||||
|
def fake_check_port(host, port):
|
||||||
|
scanned_ports.append(port)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(discovery, "_check_port", fake_check_port)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.model_discovery.discover_tailscale_hosts", lambda: [],
|
||||||
|
)
|
||||||
|
|
||||||
|
discovery.discover_models()
|
||||||
|
assert 8080 in scanned_ports
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# _fingerprint_provider — llama-server via /props
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestLlamaCppFingerprint:
|
||||||
|
# A representative llama-server /props payload (trimmed to the keys the
|
||||||
|
# fingerprint relies on).
|
||||||
|
LLAMACPP_PROPS = {
|
||||||
|
"default_generation_settings": {"n_ctx": 4096, "temperature": 0.8},
|
||||||
|
"total_slots": 1,
|
||||||
|
"chat_template": "{{ messages }}",
|
||||||
|
"model_path": "/models/gemma-4-12b-it-Q4_K_M.gguf",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_llamacpp_props_detected(self, monkeypatch):
|
||||||
|
"""A server that isn't LM Studio but answers /props as llama-server →
|
||||||
|
'llamacpp'."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/api/v1/models"):
|
||||||
|
# OpenAI-compatible shape, not the LM Studio native shape.
|
||||||
|
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
|
||||||
|
if url.endswith("/props"):
|
||||||
|
return _FakeResponse(self.LLAMACPP_PROPS)
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
assert discovery._fingerprint_provider("localhost", 8080) == "llamacpp"
|
||||||
|
|
||||||
|
def test_lmstudio_still_wins_when_both_match(self, monkeypatch):
|
||||||
|
"""If /api/v1/models reports the LM Studio native shape, LM Studio is
|
||||||
|
returned even when /props would also match."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
lmstudio_native = {
|
||||||
|
"models": [{"type": "llm", "key": "qwen3.6-27b",
|
||||||
|
"architecture": "qwen35", "format": "gguf"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/api/v1/models"):
|
||||||
|
return _FakeResponse(lmstudio_native)
|
||||||
|
if url.endswith("/props"):
|
||||||
|
return _FakeResponse(self.LLAMACPP_PROPS)
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
assert discovery._fingerprint_provider("localhost", 8080) == "lmstudio"
|
||||||
|
|
||||||
|
def test_props_without_llamacpp_keys_not_detected(self, monkeypatch):
|
||||||
|
"""A /props-style response lacking llama-server marker keys → None."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/api/v1/models"):
|
||||||
|
return _FakeResponse({"data": []})
|
||||||
|
if url.endswith("/props"):
|
||||||
|
return _FakeResponse({"unrelated": "value"})
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
assert discovery._fingerprint_provider("localhost", 8080) is None
|
||||||
|
|
||||||
|
def test_props_unreachable_returns_none(self, monkeypatch):
|
||||||
|
"""No /api/v1/models and a failing /props → None (not an exception)."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/api/v1/models"):
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
raise OSError("connection refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
assert discovery._fingerprint_provider("localhost", 8080) is None
|
||||||
|
|
||||||
|
def test_check_port_attaches_llamacpp_provider(self, monkeypatch):
|
||||||
|
"""End-to-end: _check_port tags a discovered llama-server as 'llamacpp'."""
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/v1/models"):
|
||||||
|
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
|
||||||
|
if url.endswith("/api/v1/models"):
|
||||||
|
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
|
||||||
|
if url.endswith("/props"):
|
||||||
|
return _FakeResponse(self.LLAMACPP_PROPS)
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
result = discovery._check_port("localhost", 8080)
|
||||||
|
assert result is not None
|
||||||
|
assert result["provider"] == "llamacpp"
|
||||||
|
assert result["models"] == ["gemma-4-12b"]
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# Docker loopback rewrite — host.docker.internal:8080 in scan
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestDockerLoopbackScan:
|
||||||
|
def test_host_docker_internal_in_scan_hosts(self, monkeypatch):
|
||||||
|
"""When no LLM_HOSTS env override is set, host.docker.internal must be
|
||||||
|
included in the scan host list so llama-server on the Docker host is
|
||||||
|
discovered from inside the container."""
|
||||||
|
monkeypatch.delenv("LLM_HOSTS", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.model_discovery.discover_tailscale_hosts", lambda: [],
|
||||||
|
)
|
||||||
|
discovery = ModelDiscovery(default_host="localhost")
|
||||||
|
hosts = discovery._get_hosts()
|
||||||
|
assert "host.docker.internal" in hosts
|
||||||
|
|
||||||
|
def test_discovered_endpoint_url_uses_provided_host(self, monkeypatch):
|
||||||
|
"""When host.docker.internal:8080 is probed, the returned base_url
|
||||||
|
contains host.docker.internal — not a rewritten 127.0.0.1."""
|
||||||
|
from src.model_discovery import ModelDiscovery as _MD
|
||||||
|
|
||||||
|
discovery = _MD(default_host="localhost")
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
if url.endswith("/v1/models") or url.endswith("/api/v1/models"):
|
||||||
|
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
|
||||||
|
if url.endswith("/props"):
|
||||||
|
return _FakeResponse({
|
||||||
|
"default_generation_settings": {"n_ctx": 4096},
|
||||||
|
"total_slots": 1,
|
||||||
|
"chat_template": "{{ messages }}",
|
||||||
|
})
|
||||||
|
return _FakeResponse({}, ok=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
|
||||||
|
result = discovery._check_port("host.docker.internal", 8080)
|
||||||
|
assert result is not None
|
||||||
|
assert "host.docker.internal" in result["url"]
|
||||||
|
assert "127.0.0.1" not in result["url"]
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Tests for _normalize_mistral_content() — Mistral's structured content parser.
|
||||||
|
|
||||||
|
Mistral's chat completions API returns content as a typed array when reasoning
|
||||||
|
is enabled, instead of the plain string most OpenAI-compat servers use:
|
||||||
|
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": [{"type": "text", "text": "..."}], "closed": true},
|
||||||
|
{"type": "text", "text": "..."}
|
||||||
|
]
|
||||||
|
|
||||||
|
_normalize_mistral_content() splits that into (text, thinking) plain strings.
|
||||||
|
The function is called from three sites:
|
||||||
|
- llm_call (sync, non-streaming response parser)
|
||||||
|
- llm_call_async (async, non-streaming response parser)
|
||||||
|
- stream_llm (streaming delta parser)
|
||||||
|
|
||||||
|
These tests pin the contract: string passthrough, the array shape, and the
|
||||||
|
edge cases (empty, garbage, missing fields) so a refactor doesn't silently
|
||||||
|
drop thinking content or break non-Mistral providers.
|
||||||
|
"""
|
||||||
|
from src.llm_core import _normalize_mistral_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_string_passthrough_returns_text_with_empty_thinking():
|
||||||
|
"""Plain string content (the common case) passes through unchanged."""
|
||||||
|
text, thinking = _normalize_mistral_content("hello world")
|
||||||
|
assert text == "hello world"
|
||||||
|
assert thinking == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_string_passthrough():
|
||||||
|
text, thinking = _normalize_mistral_content("")
|
||||||
|
assert text == ""
|
||||||
|
assert thinking == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_array_with_thinking_and_text_blocks():
|
||||||
|
"""Mistral's documented format: thinking block + text block."""
|
||||||
|
content = [
|
||||||
|
{
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": [{"type": "text", "text": "Let me work through this..."}],
|
||||||
|
"closed": True,
|
||||||
|
},
|
||||||
|
{"type": "text", "text": "The answer is 42."},
|
||||||
|
]
|
||||||
|
text, thinking = _normalize_mistral_content(content)
|
||||||
|
assert text == "The answer is 42."
|
||||||
|
assert thinking == "Let me work through this..."
|
||||||
|
|
||||||
|
|
||||||
|
def test_array_with_only_thinking_block():
|
||||||
|
"""Streaming deltas often contain only a thinking fragment (no text block yet)."""
|
||||||
|
content = [
|
||||||
|
{
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": [{"type": "text", "text": "Okay, let's"}],
|
||||||
|
"closed": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
text, thinking = _normalize_mistral_content(content)
|
||||||
|
assert text == ""
|
||||||
|
assert thinking == "Okay, let's"
|
||||||
|
|
||||||
|
|
||||||
|
def test_array_with_only_text_block():
|
||||||
|
"""Final answer delta — only the text block, no thinking."""
|
||||||
|
content = [{"type": "text", "text": "Final answer."}]
|
||||||
|
text, thinking = _normalize_mistral_content(content)
|
||||||
|
assert text == "Final answer."
|
||||||
|
assert thinking == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_array_concatenates_multiple_text_blocks():
|
||||||
|
"""Multiple text blocks are concatenated in order."""
|
||||||
|
content = [
|
||||||
|
{"type": "text", "text": "part 1 "},
|
||||||
|
{"type": "text", "text": "part 2"},
|
||||||
|
]
|
||||||
|
text, thinking = _normalize_mistral_content(content)
|
||||||
|
assert text == "part 1 part 2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_array_concatenates_multiple_thinking_fragments():
|
||||||
|
"""Multiple thinking sub-blocks are concatenated in order."""
|
||||||
|
content = [
|
||||||
|
{
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": [
|
||||||
|
{"type": "text", "text": "first "},
|
||||||
|
{"type": "text", "text": "second"},
|
||||||
|
],
|
||||||
|
"closed": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
text, thinking = _normalize_mistral_content(content)
|
||||||
|
assert text == ""
|
||||||
|
assert thinking == "first second"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_array_returns_empty_strings():
|
||||||
|
text, thinking = _normalize_mistral_content([])
|
||||||
|
assert text == ""
|
||||||
|
assert thinking == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_array_with_garbage_entries_skips_them():
|
||||||
|
"""Non-dict entries, missing type, missing text — all silently skipped."""
|
||||||
|
content = [
|
||||||
|
"not a dict",
|
||||||
|
None,
|
||||||
|
{"type": "unknown_type", "text": "should be ignored"},
|
||||||
|
{"type": "text"}, # missing text key
|
||||||
|
{"type": "thinking"}, # missing thinking key
|
||||||
|
{"type": "text", "text": "valid text"},
|
||||||
|
]
|
||||||
|
text, thinking = _normalize_mistral_content(content)
|
||||||
|
assert text == "valid text"
|
||||||
|
assert thinking == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_returns_empty_strings():
|
||||||
|
"""Defensive: None content (server bug or schema drift) doesn't crash."""
|
||||||
|
text, thinking = _normalize_mistral_content(None)
|
||||||
|
assert text == ""
|
||||||
|
assert thinking == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_returns_empty_strings():
|
||||||
|
"""Defensive: wrong-typed content doesn't crash."""
|
||||||
|
text, thinking = _normalize_mistral_content(42)
|
||||||
|
assert text == ""
|
||||||
|
assert thinking == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_thinking_block_with_string_inner():
|
||||||
|
"""Some Mistral API versions may use a string instead of an array for
|
||||||
|
the inner 'thinking' field. Accept both shapes."""
|
||||||
|
content = [
|
||||||
|
{"type": "thinking", "thinking": "inline string thinking"},
|
||||||
|
{"type": "text", "text": "answer"},
|
||||||
|
]
|
||||||
|
text, thinking = _normalize_mistral_content(content)
|
||||||
|
assert text == "answer"
|
||||||
|
assert thinking == "inline string thinking"
|
||||||
|
|
||||||
|
|
||||||
|
def test_thinking_block_with_empty_text_field():
|
||||||
|
"""Empty text fields don't pollute the output."""
|
||||||
|
content = [
|
||||||
|
{"type": "thinking", "thinking": [{"type": "text", "text": ""}], "closed": True},
|
||||||
|
{"type": "text", "text": ""},
|
||||||
|
]
|
||||||
|
text, thinking = _normalize_mistral_content(content)
|
||||||
|
assert text == ""
|
||||||
|
assert thinking == ""
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from core.log_safety import redact_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_userinfo():
|
||||||
|
assert redact_url("https://user:pass@host.example/v1/models") == "https://host.example/v1/models"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_query_and_fragment():
|
||||||
|
assert redact_url("https://host.example/v1?api_key=secret#frag") == "https://host.example/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_keeps_port_and_path():
|
||||||
|
assert redact_url("http://host.example:8080/api/tags") == "http://host.example:8080/api/tags"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ipv6_host_keeps_brackets():
|
||||||
|
assert redact_url("https://user:pass@[2001:db8::1]:8443/v1") == "https://[2001:db8::1]:8443/v1"
|
||||||
|
assert redact_url("https://[2001:db8::1]/v1") == "https://[2001:db8::1]/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_credentials_passthrough():
|
||||||
|
assert redact_url("https://host.example/v1/models") == "https://host.example/v1/models"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_and_none():
|
||||||
|
assert redact_url("") == ""
|
||||||
|
assert redact_url(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_does_not_raise():
|
||||||
|
# urlparse is lenient; just assert no credential-looking userinfo survives.
|
||||||
|
assert "@" not in redact_url("::::not a url::::")
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"""Regression: stream_agent_loop surfaces *why* a guard ended the turn.
|
||||||
|
|
||||||
|
Two internal guards used to stop the agent in ways that looked like a clean
|
||||||
|
completion or a vague blocked message:
|
||||||
|
|
||||||
|
* the loop-breaker stall detector -> now emits `loop_breaker_triggered`
|
||||||
|
* the intent-without-action nudge cap -> now emits `intent_nudge_exhausted`
|
||||||
|
|
||||||
|
These tests run the real loop body against a fake LLM stream (no model calls,
|
||||||
|
no sleeps) and assert the structured stop event is emitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import src.agent_loop as al
|
||||||
|
|
||||||
|
|
||||||
|
def _collect(gen):
|
||||||
|
async def _run():
|
||||||
|
return [c async for c in gen]
|
||||||
|
return asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def _types(chunks):
|
||||||
|
out = []
|
||||||
|
for c in chunks:
|
||||||
|
if c.startswith("data: ") and not c.startswith("data: [DONE]"):
|
||||||
|
try:
|
||||||
|
out.append(json.loads(c[6:]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_common(monkeypatch):
|
||||||
|
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
||||||
|
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
||||||
|
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
||||||
|
|
||||||
|
async def _fake_exec(block, *a, **k):
|
||||||
|
return ("bash", {"output": "ok", "exit_code": 0})
|
||||||
|
monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_loop(monkeypatch, round_text, max_rounds, relevant_tools={"bash"}):
|
||||||
|
async def _fake_stream(_candidates, messages, **kwargs):
|
||||||
|
yield f'data: {json.dumps({"delta": round_text})}\n\n'
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
|
||||||
|
|
||||||
|
gen = al.stream_agent_loop(
|
||||||
|
"http://x/v1", "m",
|
||||||
|
[{"role": "user", "content": "do a long multi-step task"}],
|
||||||
|
max_rounds=max_rounds,
|
||||||
|
relevant_tools=relevant_tools,
|
||||||
|
)
|
||||||
|
return _types(_collect(gen))
|
||||||
|
|
||||||
|
|
||||||
|
def test_emits_loop_breaker_triggered_on_repeated_no_progress(monkeypatch):
|
||||||
|
_patch_common(monkeypatch)
|
||||||
|
# Same exact tool call every round, no answer text -> stuck-round streak
|
||||||
|
# trips the loop-breaker once the cap is reached.
|
||||||
|
events = _run_loop(monkeypatch, "```bash\necho hi\n```", max_rounds=8)
|
||||||
|
lb = [e for e in events if e.get("type") == "loop_breaker_triggered"]
|
||||||
|
assert lb, events
|
||||||
|
e = lb[0]
|
||||||
|
assert e["reason"]
|
||||||
|
assert e["max_stuck_rounds"] == 4
|
||||||
|
assert e["stuck_rounds"] >= 4
|
||||||
|
assert "message" in e
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_loop_breaker_on_normal_finish(monkeypatch):
|
||||||
|
_patch_common(monkeypatch)
|
||||||
|
events = _run_loop(monkeypatch, "All done, here is your answer.", max_rounds=8)
|
||||||
|
assert not any(e.get("type") == "loop_breaker_triggered" for e in events), events
|
||||||
|
|
||||||
|
|
||||||
|
def test_emits_intent_nudge_exhausted_when_cap_reached(monkeypatch):
|
||||||
|
_patch_common(monkeypatch)
|
||||||
|
# The model keeps announcing an action with no tool call. After the nudge
|
||||||
|
# cap is spent, the turn ends with an explicit intent_nudge_exhausted event.
|
||||||
|
events = _run_loop(monkeypatch, "Let me check the logs now", max_rounds=5)
|
||||||
|
inx = [e for e in events if e.get("type") == "intent_nudge_exhausted"]
|
||||||
|
assert inx, events
|
||||||
|
e = inx[0]
|
||||||
|
assert e["max_nudges"] == 2
|
||||||
|
assert e["nudges"] >= 2
|
||||||
|
assert "message" in e
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_intent_nudge_exhausted_on_normal_finish(monkeypatch):
|
||||||
|
_patch_common(monkeypatch)
|
||||||
|
events = _run_loop(monkeypatch, "Here is the complete answer to your question.", max_rounds=5)
|
||||||
|
assert not any(e.get("type") == "intent_nudge_exhausted" for e in events), events
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_guard_log_safe(caplog, *, structural, secret="secret123"):
|
||||||
|
"""The guard's own structural log line fired, and that record carries no raw
|
||||||
|
secret. Scoped to the guard's records on purpose: an unrelated, pre-existing
|
||||||
|
round-summary log echoes raw model text and is out of scope for this PR."""
|
||||||
|
records = [r for r in caplog.records if structural in r.getMessage()]
|
||||||
|
assert records, caplog.text
|
||||||
|
for r in records:
|
||||||
|
assert secret not in r.getMessage(), r.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
def test_intent_nudge_logging_does_not_leak_secret(monkeypatch, caplog):
|
||||||
|
# The model announces an action (no tool call) with a secret in the text.
|
||||||
|
# The nudge logger must record only structural metadata, never the matched
|
||||||
|
# phrase — so the credential never lands in journalctl.
|
||||||
|
_patch_common(monkeypatch)
|
||||||
|
with caplog.at_level(logging.INFO, logger="src.agent_loop"):
|
||||||
|
events = _run_loop(monkeypatch, "Let me check api_key=secret123 now", max_rounds=5)
|
||||||
|
assert any(e.get("type") == "intent_nudge_exhausted" for e in events), events
|
||||||
|
_assert_guard_log_safe(caplog, structural="intent-without-action nudge")
|
||||||
|
|
||||||
|
|
||||||
|
def test_loop_breaker_logging_does_not_leak_secret(monkeypatch, caplog):
|
||||||
|
# A repeated tool command carrying a secret trips the loop-breaker. The
|
||||||
|
# structural log must not contain `_sig` / raw tool-call content.
|
||||||
|
_patch_common(monkeypatch)
|
||||||
|
with caplog.at_level(logging.INFO, logger="src.agent_loop"):
|
||||||
|
events = _run_loop(monkeypatch, "```bash\necho api_key=secret123\n```", max_rounds=8)
|
||||||
|
assert any(e.get("type") == "loop_breaker_triggered" for e in events), events
|
||||||
|
_assert_guard_log_safe(caplog, structural="loop-breaker tripped")
|
||||||
|
|
||||||
|
|
||||||
|
def test_redacts_sensitive_tool_output_before_surfacing():
|
||||||
|
text = al._redact_sensitive_text(
|
||||||
|
"password: private-value\n"
|
||||||
|
"api_key=private-key\n"
|
||||||
|
"Authorization: Bearer private-token\n"
|
||||||
|
"normal output"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "private-value" not in text
|
||||||
|
assert "private-key" not in text
|
||||||
|
assert "private-token" not in text
|
||||||
|
assert "password: [redacted]" in text
|
||||||
|
assert "api_key=[redacted]" in text
|
||||||
|
assert "Authorization: Bearer [redacted]" in text
|
||||||
|
assert "normal output" in text
|
||||||
|
|
||||||
|
|
||||||
|
_GCP_API_KEY_SAMPLE = "AI" + "za" + ("A" * 35)
|
||||||
|
|
||||||
|
# (input, secret substring that must be gone, expected substring that must remain)
|
||||||
|
_REDACTION_CASES = [
|
||||||
|
("Authorization: Bearer abc123tok", "abc123tok", "Authorization: Bearer [redacted]"),
|
||||||
|
("Authorization: Basic dXNlcjpwYXNz", "dXNlcjpwYXNz", "Authorization: Basic [redacted]"),
|
||||||
|
# Quoted Authorization value (spaces) must be redacted whole.
|
||||||
|
('Authorization: Bearer "two word secret"', "two word secret", "Authorization: Bearer [redacted]"),
|
||||||
|
# Escaped quote inside a quoted secret must not leak the tail.
|
||||||
|
(r'password="abc\"def secret"', "def secret", "password=[redacted]"),
|
||||||
|
# URL password containing a colon must still be redacted whole.
|
||||||
|
("postgres://user:pa:ss@host/db", "pa:ss", "postgres://[redacted]@host/db"),
|
||||||
|
# Provider-shaped bare tokens.
|
||||||
|
("token is hf_abcdefghij1234567890XYZ", "hf_abcdefghij1234567890XYZ", "[redacted]"),
|
||||||
|
("key " + _GCP_API_KEY_SAMPLE, _GCP_API_KEY_SAMPLE, "[redacted]"),
|
||||||
|
("Cookie: session=abc123secret", "abc123secret", "Cookie: [redacted]"),
|
||||||
|
("Set-Cookie: sid=xyz789; HttpOnly", "xyz789", "Set-Cookie: [redacted]"),
|
||||||
|
("postgres://user:pa55word@host/db", "pa55word", "postgres://[redacted]@host/db"),
|
||||||
|
("client_secret=supersecretvalue", "supersecretvalue", "client_secret=[redacted]"),
|
||||||
|
("OPENAI_API_KEY=abcd1234deadbeef", "abcd1234deadbeef", "OPENAI_API_KEY=[redacted]"),
|
||||||
|
# Quoted multi-word env value must be fully redacted, not clipped at the space.
|
||||||
|
('OPENAI_API_KEY="two word secret"', "two word secret", "OPENAI_API_KEY=[redacted]"),
|
||||||
|
('password: "my secret value"', "my secret value", "password: [redacted]"),
|
||||||
|
("here is sk-abcdefghij1234567890", "sk-abcdefghij1234567890", "[redacted]"),
|
||||||
|
(
|
||||||
|
"-----BEGIN PRIVATE KEY-----\nMIIfakeKEYbody\n-----END PRIVATE KEY-----",
|
||||||
|
"MIIfakeKEYbody",
|
||||||
|
"[redacted private key]",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw, secret, expected", _REDACTION_CASES)
|
||||||
|
def test_redaction_covers_requested_secret_shapes(raw, secret, expected):
|
||||||
|
out = al._redact_sensitive_text(raw)
|
||||||
|
assert secret not in out, out
|
||||||
|
assert expected in out, out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", [
|
||||||
|
"the build completed in 3.2s with 0 errors",
|
||||||
|
"password reset email sent to the user",
|
||||||
|
"Listing 5 files: a.py b.py c.py d.py e.py",
|
||||||
|
"https://example.com/path?page=2",
|
||||||
|
# Benign uppercase names that merely end in KEY must not be redacted.
|
||||||
|
"MONKEY=banana",
|
||||||
|
"TURKEY=dinner",
|
||||||
|
])
|
||||||
|
def test_redaction_keeps_normal_output_readable(raw):
|
||||||
|
assert al._redact_sensitive_text(raw) == raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_redacts_before_truncating():
|
||||||
|
# A secret near the start must be gone even if truncation would otherwise
|
||||||
|
# only clip the tail — redaction runs first.
|
||||||
|
raw = "api_key=topsecretvalue " + ("x" * 50_000)
|
||||||
|
out = al._truncate(al._redact_sensitive_text(raw))
|
||||||
|
assert "topsecretvalue" not in out
|
||||||
|
assert "api_key=[redacted]" in out
|
||||||
|
|
||||||
|
|
||||||
|
def _run_tool_result(monkeypatch, tool, exec_result, max_rounds=2):
|
||||||
|
"""Drive one tool round whose execution returns `exec_result`, and collect
|
||||||
|
the streamed events. Used to assert restored per-tool-result emissions."""
|
||||||
|
_patch_common(monkeypatch)
|
||||||
|
|
||||||
|
async def _fake_exec(block, *a, **k):
|
||||||
|
return (tool, exec_result)
|
||||||
|
monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False)
|
||||||
|
|
||||||
|
round_text = f"```{tool}\n{{}}\n```"
|
||||||
|
|
||||||
|
async def _fake_stream(_candidates, messages, **kwargs):
|
||||||
|
yield f'data: {json.dumps({"delta": round_text})}\n\n'
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
|
||||||
|
|
||||||
|
gen = al.stream_agent_loop(
|
||||||
|
"http://x/v1", "m",
|
||||||
|
[{"role": "user", "content": "do something"}],
|
||||||
|
max_rounds=max_rounds,
|
||||||
|
relevant_tools={tool},
|
||||||
|
)
|
||||||
|
return _types(_collect(gen))
|
||||||
|
|
||||||
|
|
||||||
|
def test_restores_doc_suggestions_event(monkeypatch):
|
||||||
|
events = _run_tool_result(
|
||||||
|
monkeypatch, "suggest_document",
|
||||||
|
{"action": "suggest", "doc_id": "d1", "suggestions": [{"text": "x"}], "exit_code": 0},
|
||||||
|
)
|
||||||
|
assert any(e.get("type") == "doc_suggestions" for e in events), events
|
||||||
|
|
||||||
|
|
||||||
|
def test_restores_doc_update_event(monkeypatch):
|
||||||
|
events = _run_tool_result(
|
||||||
|
monkeypatch, "edit_document",
|
||||||
|
{"action": "edit", "doc_id": "d1", "content": "body", "version": 2,
|
||||||
|
"title": "T", "language": "md", "exit_code": 0},
|
||||||
|
)
|
||||||
|
# A native document block also emits doc_update AFTER tool_output, so a plain
|
||||||
|
# "any doc_update" check would pass even if the restored generic block were
|
||||||
|
# gone. Prove the restored block fires BEFORE the first tool_output.
|
||||||
|
types = [e.get("type") for e in events]
|
||||||
|
assert "doc_update" in types, events
|
||||||
|
assert "tool_output" in types, events
|
||||||
|
assert types.index("doc_update") < types.index("tool_output"), types
|
||||||
|
|
||||||
|
|
||||||
|
def test_restores_ui_control_event(monkeypatch):
|
||||||
|
events = _run_tool_result(
|
||||||
|
monkeypatch, "ui_control",
|
||||||
|
{"ui_event": "toggle", "toggle_name": "bash", "state": "off", "exit_code": 0},
|
||||||
|
)
|
||||||
|
assert any(e.get("type") == "ui_control" for e in events), events
|
||||||
|
|
||||||
|
|
||||||
|
def test_restores_plan_update_event(monkeypatch):
|
||||||
|
events = _run_tool_result(
|
||||||
|
monkeypatch, "update_plan",
|
||||||
|
{"plan_update": {"steps": [{"text": "step", "done": True}]}, "exit_code": 0},
|
||||||
|
)
|
||||||
|
assert any(e.get("type") == "plan_update" for e in events), events
|
||||||
|
|
||||||
|
|
||||||
|
def test_restores_ask_user_event_and_persists_question(monkeypatch):
|
||||||
|
events = _run_tool_result(
|
||||||
|
monkeypatch, "ask_user",
|
||||||
|
{"ask_user": {"question": "Which option?", "options": [{"label": "A"}, {"label": "B"}]},
|
||||||
|
"exit_code": 0},
|
||||||
|
)
|
||||||
|
# Exactly one ask_user event — not re-emitted on a follow-up round.
|
||||||
|
_ask_events = [e for e in events if e.get("type") == "ask_user"]
|
||||||
|
assert len(_ask_events) == 1, events
|
||||||
|
# The question is streamed as assistant text so it persists for replay.
|
||||||
|
# Upstream prepends "\n\n" when full_response already holds streamed text,
|
||||||
|
# so match on containment — and it must be streamed exactly once.
|
||||||
|
_q_deltas = [e for e in events if "Which option?" in (e.get("delta") or "")]
|
||||||
|
assert len(_q_deltas) == 1, events
|
||||||
|
# Setting `_awaiting_user` breaks the loop, so the turn does NOT advance into
|
||||||
|
# another agent round (which would emit an agent_step event) after the ask.
|
||||||
|
assert not any(e.get("type") == "agent_step" for e in events), events
|
||||||
|
|
||||||
|
|
||||||
|
def test_redacts_command_display_in_streamed_events(monkeypatch):
|
||||||
|
# A tool command line can carry a secret. The streamed command display
|
||||||
|
# (tool_start / tool_output) must be redacted, even though the real command
|
||||||
|
# passed to execution is left untouched.
|
||||||
|
_patch_common(monkeypatch)
|
||||||
|
|
||||||
|
round_text = "```bash\necho api_key=secret123\n```"
|
||||||
|
|
||||||
|
async def _fake_stream(_candidates, messages, **kwargs):
|
||||||
|
yield f'data: {json.dumps({"delta": round_text})}\n\n'
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
|
||||||
|
|
||||||
|
gen = al.stream_agent_loop(
|
||||||
|
"http://x/v1", "m",
|
||||||
|
[{"role": "user", "content": "run it"}],
|
||||||
|
max_rounds=2,
|
||||||
|
relevant_tools={"bash"},
|
||||||
|
)
|
||||||
|
events = _types(_collect(gen))
|
||||||
|
cmds = [e for e in events if e.get("type") in ("tool_start", "tool_output")]
|
||||||
|
assert cmds, events
|
||||||
|
assert all("secret123" not in (e.get("command") or "") for e in cmds), cmds
|
||||||
|
assert any("api_key=[redacted]" in (e.get("command") or "") for e in cmds), cmds
|
||||||
|
|
||||||
|
|
||||||
|
def test_redacts_live_tool_progress_tail(monkeypatch):
|
||||||
|
# A secret in the live progress tail must be redacted before streaming —
|
||||||
|
# otherwise it flashes by before the (already redacted) final tool_output.
|
||||||
|
_patch_common(monkeypatch)
|
||||||
|
|
||||||
|
async def _fake_exec(block, *a, **k):
|
||||||
|
await k["progress_cb"]({"tail": "api_key=secret123", "elapsed_s": 1})
|
||||||
|
return ("bash", {"output": "done", "exit_code": 0})
|
||||||
|
monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False)
|
||||||
|
|
||||||
|
round_text = "```bash\necho hi\n```"
|
||||||
|
|
||||||
|
async def _fake_stream(_candidates, messages, **kwargs):
|
||||||
|
yield f'data: {json.dumps({"delta": round_text})}\n\n'
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
|
||||||
|
|
||||||
|
gen = al.stream_agent_loop(
|
||||||
|
"http://x/v1", "m",
|
||||||
|
[{"role": "user", "content": "run it"}],
|
||||||
|
max_rounds=2,
|
||||||
|
relevant_tools={"bash"},
|
||||||
|
)
|
||||||
|
events = _types(_collect(gen))
|
||||||
|
prog = [e for e in events if e.get("type") == "tool_progress"]
|
||||||
|
assert prog, events
|
||||||
|
assert all("secret123" not in (e.get("tail") or "") for e in prog), prog
|
||||||
|
assert any("api_key=[redacted]" in (e.get("tail") or "") for e in prog), prog
|
||||||
|
# Other fields are preserved.
|
||||||
|
assert any(e.get("elapsed_s") == 1 for e in prog), prog
|
||||||
@@ -26,8 +26,8 @@ clear_fake_database_modules()
|
|||||||
|
|
||||||
import core.database as cdb
|
import core.database as cdb
|
||||||
from core.database import McpServer
|
from core.database import McpServer
|
||||||
import src.tool_implementations as ti
|
import src.agent_tools.admin_tools as ti # do_manage_mcp/get_mcp_manager moved here in the registry migration
|
||||||
from src.tool_implementations import _validate_mcp_command
|
from src.agent_tools.admin_tools import _validate_mcp_command
|
||||||
|
|
||||||
_TS, _ENGINE, _TMPDB = make_temp_sqlite(cdb.Base.metadata)
|
_TS, _ENGINE, _TMPDB = make_temp_sqlite(cdb.Base.metadata)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import src.settings as settings_mod
|
import src.settings as settings_mod
|
||||||
from src.tool_implementations import do_manage_settings
|
from src.agent_tools.admin_tools import do_manage_settings
|
||||||
|
|
||||||
|
|
||||||
def test_set_token_budget_is_not_refused_as_secret(monkeypatch):
|
def test_set_token_budget_is_not_refused_as_secret(monkeypatch):
|
||||||
|
|||||||
@@ -170,6 +170,36 @@ def test_extract_thinking_blocks_handles_thought_tag(node_available):
|
|||||||
assert result["content"] == "Final answer."
|
assert result["content"] == "Final answer."
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_inside_inline_code_is_not_autolinked(node_available):
|
||||||
|
# A URL inside a backtick span is preceded by a space, so the bare-URL
|
||||||
|
# autolink used to wrap it in an <a> tag (then swap it for an
|
||||||
|
# ___ALLOWED_HTML_ placeholder), corrupting the command shown to the user.
|
||||||
|
html = _run_markdown_case("Run `$j = irm http://127.0.0.1:3000/x` to fetch.")
|
||||||
|
|
||||||
|
assert "<code>$j = irm http://127.0.0.1:3000/x</code>" in html
|
||||||
|
assert "___ALLOWED_HTML_" not in html
|
||||||
|
assert "<a " not in html
|
||||||
|
assert 'href="http://127.0.0.1:3000/x"' not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_outside_inline_code_is_still_autolinked(node_available):
|
||||||
|
# Inline code must not disable autolinking for bare URLs elsewhere in the
|
||||||
|
# same line.
|
||||||
|
html = _run_markdown_case("Use `irm` then visit https://example.com/page now.")
|
||||||
|
|
||||||
|
assert "<code>irm</code>" in html
|
||||||
|
assert 'href="https://example.com/page"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_code_content_is_html_escaped(node_available):
|
||||||
|
# Inline code is now extracted before the global escape pass, so it must be
|
||||||
|
# escaped at extraction time (matching the fenced-code-block handling).
|
||||||
|
html = _run_markdown_case("Render `<b>$1 & 'q'</b>` literally.")
|
||||||
|
|
||||||
|
assert "<code><b>$1 & 'q'</b></code>" in html
|
||||||
|
assert "<b>" not in html
|
||||||
|
|
||||||
|
|
||||||
def test_dotted_python_import_paths_are_not_autolinked(node_available):
|
def test_dotted_python_import_paths_are_not_autolinked(node_available):
|
||||||
html = _run_markdown_case(
|
html = _run_markdown_case(
|
||||||
"from imblearn.combine import SMOTETomek\n"
|
"from imblearn.combine import SMOTETomek\n"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
def test_reconnect_passes_full_server_config():
|
def test_reconnect_passes_full_server_config():
|
||||||
"""do_manage_mcp reconnect must pass name/transport/command/args/env/url."""
|
"""do_manage_mcp reconnect must pass name/transport/command/args/env/url."""
|
||||||
from src.tool_implementations import do_manage_mcp
|
from src.agent_tools.admin_tools import do_manage_mcp
|
||||||
|
|
||||||
fake_mcp = MagicMock()
|
fake_mcp = MagicMock()
|
||||||
fake_mcp.disconnect_server = AsyncMock()
|
fake_mcp.disconnect_server = AsyncMock()
|
||||||
@@ -28,7 +28,7 @@ def test_reconnect_passes_full_server_config():
|
|||||||
fake_db = MagicMock()
|
fake_db = MagicMock()
|
||||||
fake_db.query.return_value.filter.return_value.first.return_value = fake_srv
|
fake_db.query.return_value.filter.return_value.first.return_value = fake_srv
|
||||||
|
|
||||||
with patch("src.tool_implementations.get_mcp_manager", return_value=fake_mcp), \
|
with patch("src.agent_tools.admin_tools.get_mcp_manager", return_value=fake_mcp), \
|
||||||
patch("core.database.SessionLocal", return_value=fake_db):
|
patch("core.database.SessionLocal", return_value=fake_db):
|
||||||
result = asyncio.run(do_manage_mcp(
|
result = asyncio.run(do_manage_mcp(
|
||||||
json.dumps({"action": "reconnect", "server_id": "srv-123"})
|
json.dumps({"action": "reconnect", "server_id": "srv-123"})
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle)
|
||||||
|
from src.tool_parsing import parse_tool_blocks, strip_tool_blocks
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_fenced_read_file_function_call_runs_as_read_file():
|
||||||
|
blocks = parse_tool_blocks('```bash\nread_file("notes/todo.md")\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "read_file"
|
||||||
|
assert blocks[0].content == "notes/todo.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_fenced_read_file_function_call_runs_as_read_file():
|
||||||
|
blocks = parse_tool_blocks('```python\nread_file(path="notes/todo.md", offset=3, limit=2)\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "read_file"
|
||||||
|
assert json.loads(blocks[0].content) == {
|
||||||
|
"path": "notes/todo.md",
|
||||||
|
"offset": 3,
|
||||||
|
"limit": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_fenced_read_file_command_runs_as_read_file():
|
||||||
|
blocks = parse_tool_blocks('```bash\nread_file "notes/todo.md"\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "read_file"
|
||||||
|
assert blocks[0].content == "notes/todo.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_fenced_read_file_json_command_runs_as_read_file():
|
||||||
|
blocks = parse_tool_blocks('```bash\nread_file {"path":"notes/todo.md","offset":1,"limit":4}\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "read_file"
|
||||||
|
assert json.loads(blocks[0].content) == {
|
||||||
|
"path": "notes/todo.md",
|
||||||
|
"offset": 1,
|
||||||
|
"limit": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiline_bash_read_file_block_stays_bash():
|
||||||
|
blocks = parse_tool_blocks('```bash\nread_file notes/todo.md\necho done\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "bash"
|
||||||
|
assert "read_file notes/todo.md" in blocks[0].content
|
||||||
|
|
||||||
|
|
||||||
|
def test_nontrivial_python_read_file_name_stays_python_code():
|
||||||
|
blocks = parse_tool_blocks('```python\nprint(read_file("notes/todo.md"))\n```')
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "python"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_tool_blocks_removes_rescued_read_file_fence():
|
||||||
|
text = 'Opening file:\n```bash\nread_file "notes/todo.md"\n```\nDone.'
|
||||||
|
|
||||||
|
cleaned = strip_tool_blocks(text)
|
||||||
|
|
||||||
|
assert "```" not in cleaned
|
||||||
|
assert "read_file" not in cleaned
|
||||||
|
assert "Opening file:" in cleaned
|
||||||
|
assert "Done." in cleaned
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Tests for share_defaults_with_users setting"""
|
||||||
|
import pytest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from tests.helpers.import_state import preserve_import_state
|
||||||
|
from tests.helpers.db_stubs import make_core_db_stub
|
||||||
|
|
||||||
|
with preserve_import_state("core.database", "src.database", "routes.model_routes", "routes.prefs_routes"):
|
||||||
|
import routes.model_routes as model_routes
|
||||||
|
import routes.prefs_routes as prefs_routes
|
||||||
|
import src.auth_helpers as auth_helpers
|
||||||
|
|
||||||
|
|
||||||
|
### Helper Classes
|
||||||
|
|
||||||
|
class _FakeEndpoint:
|
||||||
|
"""Minimal fake endpoint for testing"""
|
||||||
|
def __init__(self, id, base_url, is_enabled=True, owner=None):
|
||||||
|
self.id = id
|
||||||
|
self.base_url = base_url
|
||||||
|
self.is_enabled = is_enabled
|
||||||
|
self.owner = owner
|
||||||
|
self.cached_models = None
|
||||||
|
self.hidden_models = None
|
||||||
|
self.pinned_models = None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
"""Fake query object for testing"""
|
||||||
|
def __init__(self, endpoints, user=None, include_shared=True):
|
||||||
|
self._endpoints = endpoints
|
||||||
|
self._user = user
|
||||||
|
self._include_shared = include_shared
|
||||||
|
|
||||||
|
def filter(self, *conditions):
|
||||||
|
for cond in conditions:
|
||||||
|
cond_str = str(cond)
|
||||||
|
print(f"Filter condition: {cond_str}")
|
||||||
|
if 'owner' in cond_str and 'IS NULL' not in cond_str:
|
||||||
|
self._include_shared = False
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
"""Return first endpoint respecting owner filter"""
|
||||||
|
if not self._endpoints:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self._user:
|
||||||
|
for ep in self._endpoints:
|
||||||
|
ep_owner = getattr(ep, 'owner', None)
|
||||||
|
if ep_owner == self._user:
|
||||||
|
return ep
|
||||||
|
if self._include_shared and ep_owner is None:
|
||||||
|
return ep
|
||||||
|
return None
|
||||||
|
return self._endpoints[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_session(endpoints, user=None):
|
||||||
|
"""Create a fake DB session that returns our fake query"""
|
||||||
|
fake_session = MagicMock()
|
||||||
|
fake_query = _FakeQuery(endpoints, user)
|
||||||
|
fake_session.query.return_value = fake_query
|
||||||
|
return fake_session
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_chat_route(router):
|
||||||
|
"""Extract the /api/default-chat GET route from the router"""
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", "") == "/api/default-chat" and "GET" in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError("GET /api/default-chat route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(user=None, auth_manager=None):
|
||||||
|
"""Create a fake request for testing"""
|
||||||
|
return SimpleNamespace(
|
||||||
|
state=SimpleNamespace(current_user=user),
|
||||||
|
app=SimpleNamespace(state=SimpleNamespace(auth_manager=auth_manager)),
|
||||||
|
client=SimpleNamespace(host="127.0.0.1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
### Shared test logic
|
||||||
|
def _run_get_default_chat_test(monkeypatch, share_defaults_enabled, second_endpoint_only=False):
|
||||||
|
"""Helper function that runs get_default_chat with the given share_defaults_with_users setting."""
|
||||||
|
|
||||||
|
global_settings = {
|
||||||
|
"default_endpoint_id": "global-ep-123",
|
||||||
|
"default_model": "qwen-3.6",
|
||||||
|
"default_model_fallbacks": [
|
||||||
|
{"endpoint_id": "fallback-ep", "model": "fallback-model"}
|
||||||
|
],
|
||||||
|
"share_defaults_with_users": share_defaults_enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_load_settings", lambda: global_settings)
|
||||||
|
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda user: {})
|
||||||
|
|
||||||
|
fake_auth_manager = MagicMock()
|
||||||
|
fake_auth_manager.is_admin = lambda user: False
|
||||||
|
|
||||||
|
endpoints = [
|
||||||
|
_FakeEndpoint(
|
||||||
|
id="global-ep-123",
|
||||||
|
base_url="http://global-endpoint:8000/v1",
|
||||||
|
is_enabled=True
|
||||||
|
),
|
||||||
|
_FakeEndpoint(
|
||||||
|
id="fallback-ep",
|
||||||
|
base_url="http://fallback-endpoint:8000/v1",
|
||||||
|
is_enabled=True
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# When testing fallback scenario, removes the primary endpoint
|
||||||
|
if second_endpoint_only:
|
||||||
|
endpoints = [endpoints[1]]
|
||||||
|
|
||||||
|
fake_db = _make_db_session(endpoints, user="regular_user")
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: fake_db)
|
||||||
|
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url)
|
||||||
|
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat")
|
||||||
|
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
get_default_chat = _get_default_chat_route(router)
|
||||||
|
fake_request = _make_request(user="regular_user", auth_manager=fake_auth_manager)
|
||||||
|
|
||||||
|
result = get_default_chat(fake_request)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
### Test Functions
|
||||||
|
|
||||||
|
def test_get_default_chat_user_no_prefs_share_disabled_resolves_nothing(monkeypatch):
|
||||||
|
"""
|
||||||
|
Non-admin user without personal preferences should resolve to empty
|
||||||
|
ep_id, model, and fallbacks when share_defaults_with_users is disabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=False)
|
||||||
|
|
||||||
|
assert test_data["endpoint_id"] == "", "Should get empty endpoint_id"
|
||||||
|
assert test_data["model"] == "", "Should get empty model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults_fallbacks(monkeypatch):
|
||||||
|
"""
|
||||||
|
Non-admin user without personal preferences should resolve to global
|
||||||
|
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True)
|
||||||
|
|
||||||
|
assert test_data["model"] == "qwen-3.6", \
|
||||||
|
"model should be resolved from global default_model"
|
||||||
|
|
||||||
|
assert test_data["endpoint_id"] == "global-ep-123", \
|
||||||
|
"Should get global endpoint_id"
|
||||||
|
|
||||||
|
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults(monkeypatch):
|
||||||
|
"""
|
||||||
|
Non-admin user without personal preferences should resolve to global
|
||||||
|
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True, second_endpoint_only=True)
|
||||||
|
|
||||||
|
assert test_data["model"] == "qwen-3.6", \
|
||||||
|
"model should be resolved from global default_model"
|
||||||
|
|
||||||
|
assert test_data["endpoint_id"] == "fallback-ep", \
|
||||||
|
"Should get global endpoint_id"
|
||||||
@@ -403,6 +403,12 @@ class TestIsChatModel:
|
|||||||
def test_legacy_openai_instruct_is_not_chat(self):
|
def test_legacy_openai_instruct_is_not_chat(self):
|
||||||
assert _is_chat_model("gpt-3.5-turbo-instruct") is False
|
assert _is_chat_model("gpt-3.5-turbo-instruct") is False
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [None, 123, 4.5, ["x"], {"a": 1}])
|
||||||
|
def test_non_string_id_is_treated_as_chat(self, bad):
|
||||||
|
# Defensive boundary: a non-compliant upstream can yield a non-string
|
||||||
|
# model id; it must not crash on .lower() (treated as chat-capable).
|
||||||
|
assert _is_chat_model(bad) is True
|
||||||
|
|
||||||
|
|
||||||
# ── _classify_endpoint ──
|
# ── _classify_endpoint ──
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Regression tests for Ollama-native multimodal image routing (issue #4723).
|
||||||
|
|
||||||
|
Odysseus builds user messages in OpenAI style::
|
||||||
|
|
||||||
|
{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": "..."},
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}},
|
||||||
|
]}
|
||||||
|
|
||||||
|
Native Ollama ``/api/chat`` does **not** accept a list for ``content``. It
|
||||||
|
expects ``content`` to be a string and images carried separately on
|
||||||
|
``images`` (a list of raw base64 strings, no ``data:`` prefix). Without
|
||||||
|
this conversion the image block silently never reaches the vision model —
|
||||||
|
the model reports "I can't see the image" even though it is vision-capable
|
||||||
|
and the request succeeded.
|
||||||
|
"""
|
||||||
|
from src import llm_core
|
||||||
|
|
||||||
|
|
||||||
|
def _multimodal_msg():
|
||||||
|
return {
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "What is in this picture?"},
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,BBBB"}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ollama_payload_converts_openai_image_blocks_to_native_images_array():
|
||||||
|
payload = llm_core._build_ollama_payload(
|
||||||
|
"gemma4:e4b", [_multimodal_msg()], temperature=0.0, max_tokens=0,
|
||||||
|
)
|
||||||
|
msg = payload["messages"][0]
|
||||||
|
# Content must be a string, not a list — native Ollama rejects lists.
|
||||||
|
assert isinstance(msg["content"], str)
|
||||||
|
assert "What is in this picture?" in msg["content"]
|
||||||
|
# Base64 data extracted into the native images array (no data: prefix).
|
||||||
|
assert msg["images"] == ["AAAA", "BBBB"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ollama_payload_skips_http_image_url():
|
||||||
|
"""Non-data-URI image_url values are skipped with a warning because
|
||||||
|
native Ollama images[] accepts base64 only."""
|
||||||
|
msg = {
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Look"},
|
||||||
|
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||||
|
out = payload["messages"][0]
|
||||||
|
assert out["content"] == "Look"
|
||||||
|
# HTTP URL is NOT added to images — Ollama cannot fetch it.
|
||||||
|
assert "images" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_ollama_payload_preserves_native_images_array():
|
||||||
|
"""If the caller already used Ollama's native shape, leave it alone."""
|
||||||
|
msg = {
|
||||||
|
"role": "user",
|
||||||
|
"content": "Describe",
|
||||||
|
"images": ["XXXX"],
|
||||||
|
}
|
||||||
|
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||||
|
out = payload["messages"][0]
|
||||||
|
assert out["content"] == "Describe"
|
||||||
|
assert out["images"] == ["XXXX"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ollama_payload_merges_native_and_openai_images():
|
||||||
|
"""A message that carries both native ``images`` and OpenAI ``image_url``
|
||||||
|
blocks (e.g. assembled by different code paths) must produce one combined
|
||||||
|
list rather than drop either half."""
|
||||||
|
msg = {
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Hi"},
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,OPENAI"}},
|
||||||
|
],
|
||||||
|
"images": ["NATIVE"],
|
||||||
|
}
|
||||||
|
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||||
|
out = payload["messages"][0]
|
||||||
|
assert out["content"] == "Hi"
|
||||||
|
assert out["images"] == ["NATIVE", "OPENAI"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ollama_payload_text_only_message_untouched():
|
||||||
|
msgs = [{"role": "user", "content": "hello"}]
|
||||||
|
payload = llm_core._build_ollama_payload("gemma4:e4b", msgs, temperature=0.0, max_tokens=0)
|
||||||
|
assert payload["messages"][0] == {"role": "user", "content": "hello"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ollama_payload_string_content_with_only_image_block():
|
||||||
|
"""A message whose content list has only image_url blocks (no text part)
|
||||||
|
still yields a non-empty content string so native Ollama accepts it."""
|
||||||
|
msg = {
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,QQ=="}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||||
|
out = payload["messages"][0]
|
||||||
|
assert isinstance(out["content"], str)
|
||||||
|
assert out["images"] == ["QQ=="]
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Node-driven regression coverage for body-portaled dropdown z-order.
|
||||||
|
|
||||||
|
Tool-modal z climbs unbounded via modalManager's bring-to-front counter, so the
|
||||||
|
old hardcoded `z-index: 10001` shared by ~16 body-portaled dropdowns eventually
|
||||||
|
rendered them BEHIND their own modal in a long session (#4720). topPortalZ()
|
||||||
|
replaces every one of those literals with a value derived from the live
|
||||||
|
tool-window stack. These tests pin that it always clears both the modal stack
|
||||||
|
and the dock-chip floor, without importing the browser-heavy UI modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
HELPER = ROOT / "static" / "js" / "toolWindowZOrder.js"
|
||||||
|
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def _node_eval(source: str):
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=source,
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_z_clears_dock_chip_floor_when_no_modal_is_open():
|
||||||
|
# No tool window raised → topToolWindowZ floors at 250, but a portaled
|
||||||
|
# dropdown must still clear the dock chips pinned up to 10030, so it lands
|
||||||
|
# just above that floor.
|
||||||
|
values = _node_eval(
|
||||||
|
textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
import {{ topPortalZ }} from '{HELPER.as_uri()}';
|
||||||
|
const root = {{ querySelectorAll() {{ return []; }} }};
|
||||||
|
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: () => ({{}}) }}) }}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert values == {"z": 10031}
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_z_sits_above_a_modal_whose_counter_has_climbed_past_10001():
|
||||||
|
# The #4720 scenario: a long session bumped the owning modal's bring-to-front
|
||||||
|
# z to 99999. A hardcoded 10001 dropdown rendered BEHIND it; topPortalZ must
|
||||||
|
# land one above the live modal z.
|
||||||
|
values = _node_eval(
|
||||||
|
textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
import {{ topPortalZ }} from '{HELPER.as_uri()}';
|
||||||
|
const cls = (...names) => ({{ contains: (name) => names.includes(name) }});
|
||||||
|
const modal = {{ id: 'memory-modal', classList: cls(), style: {{ zIndex: '99999' }} }};
|
||||||
|
const root = {{ querySelectorAll() {{ return [modal]; }} }};
|
||||||
|
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert values == {"z": 100000}
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_z_uses_chip_floor_when_the_open_modal_sits_below_it():
|
||||||
|
# A modal raised to 5000 is still below the dock-chip floor, so the floor
|
||||||
|
# (10030) wins and the dropdown lands at 10031 — never below a pinned chip.
|
||||||
|
values = _node_eval(
|
||||||
|
textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
import {{ topPortalZ }} from '{HELPER.as_uri()}';
|
||||||
|
const cls = (...names) => ({{ contains: (name) => names.includes(name) }});
|
||||||
|
const modal = {{ id: 'cookbook-modal', classList: cls(), style: {{ zIndex: '5000' }} }};
|
||||||
|
const root = {{ querySelectorAll() {{ return [modal]; }} }};
|
||||||
|
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert values == {"z": 10031}
|
||||||
|
|
||||||
|
|
||||||
|
# tasks.js and skills.js were not in #4724's batch; #4767 routes their portaled
|
||||||
|
# dropdowns through the same helper. Pin that they use topPortalZ() and carry no
|
||||||
|
# hardcoded portal z-index, so they cannot regress to the #4720 bug.
|
||||||
|
@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js"])
|
||||||
|
def test_late_routed_dropdowns_use_top_portal_z(rel):
|
||||||
|
src = (ROOT / rel).read_text()
|
||||||
|
assert "topPortalZ" in src, f"{rel} must import/use topPortalZ()"
|
||||||
|
assert "topPortalZ()" in src, f"{rel} must call topPortalZ() for its dropdown z"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js", "static/style.css"])
|
||||||
|
def test_no_hardcoded_portal_z_literals_remain(rel):
|
||||||
|
src = (ROOT / rel).read_text()
|
||||||
|
# Match the exact 100000/100002 these dropdowns used; the trailing-digit
|
||||||
|
# guard avoids false-matching an unrelated 1000000 elsewhere.
|
||||||
|
hits = re.findall(r"z-index:\s*10000[02](?!\d)", src)
|
||||||
|
assert not hits, f"{rel} still has hardcoded portal z: {hits}"
|
||||||
@@ -93,10 +93,19 @@ class TestProviderLabel:
|
|||||||
def test_known_labels(self, url, expected):
|
def test_known_labels(self, url, expected):
|
||||||
assert _provider_label(url) == expected
|
assert _provider_label(url) == expected
|
||||||
|
|
||||||
def test_local_non_ollama_endpoint(self):
|
@pytest.mark.parametrize("url", [
|
||||||
# A loopback host that isn't on the native Ollama /api path is just a
|
"http://localhost:8080/v1",
|
||||||
# generic local endpoint (e.g. an OpenAI-compatible local server).
|
"http://127.0.0.1:8080/v1",
|
||||||
assert _provider_label("http://localhost:8080/v1") == "local endpoint"
|
"http://localhost:8000/v1",
|
||||||
|
"http://localhost:1234/v1",
|
||||||
|
"http://localhost:9999/v1",
|
||||||
|
])
|
||||||
|
def test_local_non_ollama_endpoint(self, url):
|
||||||
|
# The serving tool is NOT inferred from the port: vLLM, SGLang, llama.cpp
|
||||||
|
# and plain OpenAI-compatible servers all share 8000/8080, so a port-only
|
||||||
|
# label would mislabel real setups. The tool is identified by /props
|
||||||
|
# fingerprinting during discovery; this helper stays neutral.
|
||||||
|
assert _provider_label(url) == "local endpoint"
|
||||||
|
|
||||||
def test_unknown_host_returns_host(self):
|
def test_unknown_host_returns_host(self):
|
||||||
assert _provider_label("https://api.unknown-llm.example/v1") == "api.unknown-llm.example"
|
assert _provider_label("https://api.unknown-llm.example/v1") == "api.unknown-llm.example"
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""providerLabel() in providers.js must NOT name the serving tool from the port,
|
||||||
|
mirroring the Python _provider_label() in src/llm_core.py.
|
||||||
|
|
||||||
|
A port is not authoritative: vLLM, SGLang, llama.cpp and plain OpenAI-compatible
|
||||||
|
servers all routinely share 8000/8080, so a port-only label would mislabel real
|
||||||
|
setups (e.g. a vLLM box on :8080 shown as "llama.cpp"). The actual tool is
|
||||||
|
identified by probing /props during discovery and stored as the endpoint's name.
|
||||||
|
The rule here: loopback → "Local"; private-LAN IPs → "Local"; known remote
|
||||||
|
provider hosts → their provider name.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_SRC = _REPO / "static" / "js" / "providers.js"
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_label(url: str) -> str | None:
|
||||||
|
src = _SRC.read_text(encoding="utf-8")
|
||||||
|
# Strip the `export` keyword so the module runs standalone.
|
||||||
|
src_runnable = src.replace("export function providerLabel", "function providerLabel")
|
||||||
|
src_runnable = src_runnable.replace("export default {", "const _default = {")
|
||||||
|
js = src_runnable + f"\nconsole.log(JSON.stringify(providerLabel({json.dumps(url)})));"
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=js, capture_output=True, text=True, encoding="utf-8",
|
||||||
|
cwd=str(_REPO), timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
@pytest.mark.parametrize("url,expected", [
|
||||||
|
# Loopback never names the tool from the port — it isn't authoritative.
|
||||||
|
("http://localhost:8080/v1", "Local"),
|
||||||
|
("http://127.0.0.1:8080/v1", "Local"),
|
||||||
|
("http://localhost:8000/v1", "Local"),
|
||||||
|
("http://localhost:1234/v1", "Local"),
|
||||||
|
("http://localhost:11434/api", "Local"),
|
||||||
|
("http://localhost:9999/v1", "Local"),
|
||||||
|
# Known remote provider hosts are still labeled by host suffix.
|
||||||
|
("https://api.openai.com/v1", "OpenAI"),
|
||||||
|
("https://api.groq.com/openai/v1","Groq"),
|
||||||
|
("http://192.168.1.50:8080", "Local"), # private LAN: no port branding
|
||||||
|
])
|
||||||
|
def test_provider_label_neutral_for_loopback(url, expected):
|
||||||
|
assert _provider_label(url) == expected
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
r"""Regression test for ReDoS in the calendar-extract fallback regex.
|
||||||
|
|
||||||
|
CodeQL `py/redos` (#198) flagged the inline array-matcher in
|
||||||
|
`email_pollers.py` that recovers a `[{"action": ...}, ...]` JSON array from
|
||||||
|
raw LLM output (influenced by attacker-supplied email bodies). The original
|
||||||
|
pattern used `[^[\]]*?` lazy runs inside a `(...)*` repetition, which
|
||||||
|
backtracks *exponentially* on inputs like `[{"action"},{` + `}},{{` * N.
|
||||||
|
|
||||||
|
The regex is now a module-level constant so it can be pinned here. These tests
|
||||||
|
assert it (a) still extracts well-formed action arrays and (b) returns
|
||||||
|
promptly on the adversarial input that hung the old pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from routes.email_pollers import _CAL_ACTION_ARRAY_RE
|
||||||
|
|
||||||
|
|
||||||
|
def _matches(s):
|
||||||
|
return [m.group() for m in _CAL_ACTION_ARRAY_RE.finditer(s)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extracts_action_array_from_prose():
|
||||||
|
s = 'Here you go:\n[{"action":"add","title":"Standup","start":"2026-07-01T09:00"}]\nThanks!'
|
||||||
|
assert _matches(s) == ['[{"action":"add","title":"Standup","start":"2026-07-01T09:00"}]']
|
||||||
|
|
||||||
|
|
||||||
|
def test_extracts_multi_object_array():
|
||||||
|
s = 'prose [{"action":"add","title":"A"},{"action":"cancel","uid":"x"}] tail'
|
||||||
|
assert _matches(s) == ['[{"action":"add","title":"A"},{"action":"cancel","uid":"x"}]']
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_array_returns_no_match():
|
||||||
|
assert _matches("no array here at all") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_bracket_in_string_value_still_extracts():
|
||||||
|
# The old `[^[\]]` class bailed on a '[' inside a value and matched nothing;
|
||||||
|
# the linear `[^{}]` form correctly recovers the array.
|
||||||
|
s = '[{"action":"add","title":"Meeting [urgent]","start":"x"}]'
|
||||||
|
assert _matches(s) == [s]
|
||||||
|
|
||||||
|
|
||||||
|
def test_adversarial_input_is_fast():
|
||||||
|
evil = '[{"action"},{' + '}},{{' * 100_000 # exploded the old exponential pattern
|
||||||
|
start = time.perf_counter()
|
||||||
|
_CAL_ACTION_ARRAY_RE.search(evil)
|
||||||
|
dt = time.perf_counter() - start
|
||||||
|
assert dt < 1.0, f"_CAL_ACTION_ARRAY_RE took {dt:.2f}s on adversarial input"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user