mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-08 06:02:30 +01:00
Compare commits
6
Commits
7e5db9a3c6
...
119228a6db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
119228a6db | ||
|
|
8f5e36a079 | ||
|
|
30dd789351 | ||
|
|
e8175c9535 | ||
|
|
bd9149f79a | ||
|
|
fef08ed114 |
@@ -44,7 +44,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 +65,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,22 +791,14 @@ 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")
|
root_path = abs_join(BASE_DIR, "index.html")
|
||||||
if os.path.exists(root_path):
|
if os.path.exists(root_path):
|
||||||
return _serve_html_with_nonce(request, root_path)
|
return serve_html_with_nonce(request, root_path)
|
||||||
raise HTTPException(404, "index.html not found")
|
raise HTTPException(404, "index.html not found")
|
||||||
|
|
||||||
@app.get("/notes")
|
@app.get("/notes")
|
||||||
@@ -848,13 +840,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():
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -19144,4 +19276,4 @@
|
|||||||
],
|
],
|
||||||
"_discovered": true
|
"_discovered": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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-",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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):
|
||||||
|
|||||||
+155
-28
@@ -345,43 +345,102 @@ 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
|
||||||
new_calls = []
|
|
||||||
for tc in tcs:
|
|
||||||
fn = tc.get("function") or {}
|
|
||||||
args = fn.get("arguments")
|
|
||||||
if isinstance(args, str):
|
|
||||||
try:
|
|
||||||
args = json.loads(args) if args.strip() else {}
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
args = {}
|
|
||||||
call: Dict = {"function": {"name": fn.get("name", ""), "arguments": args or {}}}
|
|
||||||
if tc.get("id"):
|
|
||||||
call["id"] = tc["id"]
|
|
||||||
new_calls.append(call)
|
|
||||||
nm = dict(m)
|
nm = dict(m)
|
||||||
nm["tool_calls"] = new_calls
|
|
||||||
|
# 1. Tool-call argument strings -> objects.
|
||||||
|
tcs = nm.get("tool_calls")
|
||||||
|
if tcs:
|
||||||
|
new_calls = []
|
||||||
|
for tc in tcs:
|
||||||
|
fn = tc.get("function") or {}
|
||||||
|
args = fn.get("arguments")
|
||||||
|
if isinstance(args, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(args) if args.strip() else {}
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
args = {}
|
||||||
|
call: Dict = {"function": {"name": fn.get("name", ""), "arguments": args or {}}}
|
||||||
|
if tc.get("id"):
|
||||||
|
call["id"] = tc["id"]
|
||||||
|
new_calls.append(call)
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
@@ -906,10 +967,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 +987,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 +1541,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 +1558,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 +1749,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 +1869,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 +2253,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)
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -407,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;
|
||||||
@@ -853,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, '');
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from './cookbookServe.js';
|
} from './cookbookServe.js';
|
||||||
|
|
||||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.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';
|
||||||
@@ -1529,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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -5512,7 +5513,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
|||||||
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>';
|
||||||
@@ -5749,7 +5750,7 @@ function _showCardMenu(em, anchor) {
|
|||||||
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>';
|
||||||
@@ -5940,7 +5941,7 @@ function _showBulkActionsMenu(anchor) {
|
|||||||
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>';
|
||||||
@@ -6221,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,6 +7,7 @@ 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 { 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;
|
||||||
@@ -2524,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>';
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+3
-3
@@ -10,7 +10,7 @@ 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';
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
@@ -4335,7 +4335,7 @@ 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 = bindMenuDismiss(menu, () => { menu.remove(); });
|
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||||
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
|
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
|
||||||
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
|
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
|
||||||
@@ -4349,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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"] == []
|
||||||
@@ -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,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,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,89 @@
|
|||||||
|
"""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 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}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Behavior tests for src.app_helpers.serve_html_with_nonce.
|
||||||
|
|
||||||
|
Every caller of this helper serves a fixed, app-bundled template
|
||||||
|
(index/login/backgrounds), never a client-supplied path. So a read failure —
|
||||||
|
a missing file (broken deployment) or a permission/IO error — is a server
|
||||||
|
fault, not a client "not found", and must surface as a logged 500 rather than
|
||||||
|
hiding behind a 404 where 5xx alerting can't see it. These tests lock that
|
||||||
|
intent (raised in the PR #4637 review).
|
||||||
|
"""
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("fastapi")
|
||||||
|
pytest.importorskip("starlette.responses")
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.app_helpers import serve_html_with_nonce
|
||||||
|
|
||||||
|
|
||||||
|
def _request_with_nonce(nonce: str = ""):
|
||||||
|
"""Minimal stand-in for a Starlette Request: only request.state.csp_nonce is read."""
|
||||||
|
return types.SimpleNamespace(state=types.SimpleNamespace(csp_nonce=nonce))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_fixed_template_returns_500_not_404(tmp_path):
|
||||||
|
missing = tmp_path / "does_not_exist.html"
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
serve_html_with_nonce(_request_with_nonce(), str(missing))
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
# Generic detail — no OS error string or absolute path leaked to the client.
|
||||||
|
assert exc_info.value.detail == "Internal server error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_template_returns_500(tmp_path):
|
||||||
|
# A directory at the path makes open() raise an OSError subtype
|
||||||
|
# (IsADirectoryError on POSIX, PermissionError on Windows) — same branch.
|
||||||
|
a_dir = tmp_path / "a_dir.html"
|
||||||
|
a_dir.mkdir()
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
serve_html_with_nonce(_request_with_nonce(), str(a_dir))
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_readable_template_injects_nonce(tmp_path):
|
||||||
|
page = tmp_path / "page.html"
|
||||||
|
page.write_text('<script nonce="{{CSP_NONCE}}">x</script>', encoding="utf-8")
|
||||||
|
resp = serve_html_with_nonce(_request_with_nonce("nonce-abc"), str(page))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.body.decode("utf-8")
|
||||||
|
assert "nonce-abc" in body
|
||||||
|
assert "{{CSP_NONCE}}" not in body
|
||||||
Reference in New Issue
Block a user