mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-09 12:07:18 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d88c8cbacf | |||
| a35384e68f | |||
| 3064819e3d | |||
| 54f1d015b5 | |||
| 2d8177035b | |||
| c67deaa60a | |||
| 168f593096 | |||
| 038bdd85ec | |||
| b5ec40d505 | |||
| 017903de61 | |||
| 35f867c959 | |||
| 2826dcfc33 | |||
| 3592285db7 | |||
| c8169ad7a9 | |||
| 5acd0ceae9 | |||
| 9dc0d661cf | |||
| a05221571c | |||
| 264da65186 | |||
| a50e30c28b | |||
| d8d98caa78 | |||
| 440d99d02c | |||
| 7d481b250c | |||
| e3750fcdcb | |||
| 439285e1b7 | |||
| 897e6950af | |||
| 6114ef0d6d | |||
| 3dd031c139 | |||
| d3faa00aaa | |||
| a3bbe37923 | |||
| 5c16d39e91 | |||
| 6f6cb6ea88 | |||
| 43ead1a0eb | |||
| d3ab478ef1 | |||
| 1f6dc80525 | |||
| 0b3338c69d | |||
| b7df800e94 | |||
| 5f6e6a2c4a | |||
| cf85c42195 | |||
| 79716d717a | |||
| d360401808 | |||
| c89258e4a6 | |||
| 5777cf2d00 | |||
| 0fc98c4a17 | |||
| 9718f7874b | |||
| 265f0911d5 | |||
| bfeea7f463 |
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report focused pytest guidance for changed paths under tests/."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
|
||||
def parse_paths(raw_paths: bytes) -> list[str]:
|
||||
"""Decode the NUL-delimited output of ``git diff --name-only -z``."""
|
||||
return [os.fsdecode(path) for path in raw_paths.split(b"\0") if path]
|
||||
|
||||
|
||||
def changed_paths_from_merge_base(base_sha: str, head_sha: str) -> list[str]:
|
||||
"""Return changed ``tests/`` paths using GitHub PR three-dot semantics.
|
||||
|
||||
GitHub PR changed files are based on the merge base and the PR head, not a
|
||||
direct endpoint diff between the current base branch tip and the PR head.
|
||||
Using the direct endpoint diff can include files changed only on the base
|
||||
branch when the PR branch is stale.
|
||||
"""
|
||||
merge_base = subprocess.check_output(
|
||||
["git", "merge-base", base_sha, head_sha],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
raw_paths = subprocess.check_output(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=ACMRT",
|
||||
"-z",
|
||||
os.fsdecode(merge_base),
|
||||
head_sha,
|
||||
"--",
|
||||
"tests/",
|
||||
],
|
||||
)
|
||||
return parse_paths(raw_paths)
|
||||
|
||||
|
||||
def select_test_paths(paths: Iterable[str]) -> list[str]:
|
||||
"""Return unique, repository-relative paths contained by tests/."""
|
||||
selected: set[str] = set()
|
||||
for raw_path in paths:
|
||||
path = PurePosixPath(raw_path)
|
||||
if path.is_absolute() or ".." in path.parts:
|
||||
continue
|
||||
parts = tuple(part for part in path.parts if part != ".")
|
||||
if len(parts) >= 2 and parts[0] == "tests":
|
||||
selected.add(PurePosixPath(*parts).as_posix())
|
||||
return sorted(selected)
|
||||
|
||||
|
||||
def is_pytest_file(path: str) -> bool:
|
||||
"""Return whether a changed path follows this repository's pytest naming."""
|
||||
name = PurePosixPath(path).name
|
||||
return name.endswith(".py") and (
|
||||
name.startswith("test_") or name.endswith("_test.py")
|
||||
)
|
||||
|
||||
|
||||
def pytest_command(paths: Iterable[str]) -> str:
|
||||
"""Build a copyable pytest command for changed runnable test files."""
|
||||
command = ["python3", "-m", "pytest", "-q", *paths]
|
||||
return shlex.join(command)
|
||||
|
||||
|
||||
def format_report(paths: Iterable[str]) -> str:
|
||||
"""Format focused guidance for CI logs and the workflow summary."""
|
||||
changed_paths = select_test_paths(paths)
|
||||
runnable_paths = [path for path in changed_paths if is_pytest_file(path)]
|
||||
lines = ["## Focused test guidance (report-only)", ""]
|
||||
if not changed_paths:
|
||||
lines.append("No changed paths under `tests/`.")
|
||||
else:
|
||||
lines.extend(["Changed paths under `tests/`:", ""])
|
||||
lines.extend(f"- `{path}`" for path in changed_paths)
|
||||
lines.extend(["", "Suggested focused validation:", ""])
|
||||
if runnable_paths:
|
||||
lines.append(f"```sh\n{pytest_command(runnable_paths)}\n```")
|
||||
else:
|
||||
lines.append("No directly runnable pytest files changed.")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"This guidance does not infer tests from source changes. "
|
||||
"Existing blocking CI remains the source of truth.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Report focused pytest guidance for changed tests/ paths.",
|
||||
)
|
||||
parser.add_argument("--base-sha", help="Pull request base commit SHA.")
|
||||
parser.add_argument("--head-sha", help="Pull request head commit SHA.")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
||||
if bool(args.base_sha) != bool(args.head_sha):
|
||||
raise SystemExit("--base-sha and --head-sha must be provided together")
|
||||
|
||||
if args.base_sha and args.head_sha:
|
||||
paths = changed_paths_from_merge_base(args.base_sha, args.head_sha)
|
||||
else:
|
||||
paths = parse_paths(sys.stdin.buffer.read())
|
||||
|
||||
print(format_report(paths))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -15,6 +15,60 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
focused-test-guidance:
|
||||
name: Focused test guidance (report-only)
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Report changed test paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
report_file="$RUNNER_TEMP/focused-test-guidance.md"
|
||||
publish_report() {
|
||||
cat "$report_file"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
cat "$report_file" >> "$GITHUB_STEP_SUMMARY" || true
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
report_unavailable() {
|
||||
{
|
||||
printf '%s\n\n' '## Focused test guidance unavailable (report-only)'
|
||||
printf '%s\n\n' "$1"
|
||||
printf '%s\n' 'Existing blocking CI remains the source of truth.'
|
||||
} > "$report_file"
|
||||
publish_report
|
||||
exit 0
|
||||
}
|
||||
|
||||
if [ -z "$BASE_SHA" ] || [ -z "$HEAD_SHA" ]; then
|
||||
report_unavailable "Pull request base/head metadata is missing."
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
||||
report_unavailable "The pull request base commit is unavailable locally."
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
|
||||
report_unavailable "The pull request head commit is unavailable locally."
|
||||
fi
|
||||
|
||||
if ! python3 .github/scripts/focused_test_guidance.py \
|
||||
--base-sha "$BASE_SHA" \
|
||||
--head-sha "$HEAD_SHA" > "$report_file"; then
|
||||
report_unavailable "The focused test guidance helper could not produce a report."
|
||||
fi
|
||||
|
||||
publish_report
|
||||
|
||||
python-syntax:
|
||||
name: Python syntax (compileall)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -3,6 +3,7 @@ import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop.
|
||||
# When started via `python -m uvicorn` from a terminal, uvicorn sets this
|
||||
@@ -204,12 +205,43 @@ class _InteractiveActivityMiddleware(_BaseHTTPMiddleware):
|
||||
path = request.url.path or ""
|
||||
if not should_track_interactive_request(path, request.method):
|
||||
return await call_next(request)
|
||||
async def _stop_background():
|
||||
try:
|
||||
await task_scheduler.stop_background_tasks_for_foreground(reason=f"foreground request {request.method} {path}")
|
||||
except Exception:
|
||||
logging.getLogger("app.foreground_gate").debug("foreground task stop failed", exc_info=True)
|
||||
asyncio.create_task(_stop_background())
|
||||
async with track_interactive_request(path, request.method):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class _SlowRequestLogMiddleware(_BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
start = time.perf_counter()
|
||||
status = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status = getattr(response, "status_code", 0) or 0
|
||||
return response
|
||||
finally:
|
||||
elapsed = time.perf_counter() - start
|
||||
try:
|
||||
threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75")
|
||||
except Exception:
|
||||
threshold = 0.75
|
||||
if elapsed >= threshold:
|
||||
logging.getLogger("app.slow_request").warning(
|
||||
"slow_request method=%s path=%s status=%s elapsed=%.3fs",
|
||||
request.method,
|
||||
request.url.path,
|
||||
status,
|
||||
elapsed,
|
||||
)
|
||||
|
||||
|
||||
app.add_middleware(_RequestTimeoutMiddleware)
|
||||
app.add_middleware(_InteractiveActivityMiddleware)
|
||||
app.add_middleware(_SlowRequestLogMiddleware)
|
||||
|
||||
# ========= AUTH =========
|
||||
from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
|
||||
@@ -600,6 +632,12 @@ app.include_router(auth_router)
|
||||
async def activity_heartbeat():
|
||||
from src.interactive_gate import mark_browser_activity
|
||||
await mark_browser_activity()
|
||||
async def _stop_background():
|
||||
try:
|
||||
await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
|
||||
except Exception:
|
||||
logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True)
|
||||
asyncio.create_task(_stop_background())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -645,7 +683,7 @@ from routes.research.research_routes import setup_research_routes
|
||||
app.include_router(setup_research_routes(research_handler, session_manager=session_manager))
|
||||
|
||||
# History
|
||||
from routes.history_routes import setup_history_routes
|
||||
from routes.history.history_routes import setup_history_routes
|
||||
app.include_router(setup_history_routes(session_manager))
|
||||
|
||||
# Search
|
||||
@@ -813,7 +851,7 @@ from routes.vault_routes import setup_vault_routes
|
||||
app.include_router(setup_vault_routes())
|
||||
|
||||
# Contacts (CardDAV)
|
||||
from routes.contacts_routes import setup_contacts_routes
|
||||
from routes.contacts.contacts_routes import setup_contacts_routes
|
||||
app.include_router(setup_contacts_routes())
|
||||
|
||||
from companion import setup_companion_routes
|
||||
@@ -889,6 +927,34 @@ async def get_version():
|
||||
async def health_check() -> Dict[str, str]:
|
||||
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||
|
||||
@app.post("/api/client-perf")
|
||||
async def client_perf(request: Request):
|
||||
"""Low-volume frontend timing reports for stalls that happen before SSE logs."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
try:
|
||||
kind = str(data.get("type") or "client").replace("\n", " ")[:80]
|
||||
total_ms = float(data.get("total_ms") or 0)
|
||||
stages = data.get("stages") if isinstance(data.get("stages"), list) else []
|
||||
stage_txt = " ".join(
|
||||
f"{str(s.get('name') or '')[:40]}={float(s.get('delta_ms') or 0):.0f}ms"
|
||||
for s in stages[:20]
|
||||
if isinstance(s, dict)
|
||||
)
|
||||
extra = str(data.get("extra") or "").replace("\n", " ")[:200]
|
||||
logging.getLogger("app.client_perf").warning(
|
||||
"client_perf type=%s total=%.0fms %s%s",
|
||||
kind,
|
||||
total_ms,
|
||||
stage_txt,
|
||||
f" extra={extra}" if extra else "",
|
||||
)
|
||||
except Exception:
|
||||
logging.getLogger("app.client_perf").debug("client_perf log failed", exc_info=True)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/ready")
|
||||
async def readiness_check() -> JSONResponse:
|
||||
"""Readiness / integrity self-check — DB, data dir, local-first storage.
|
||||
@@ -985,45 +1051,43 @@ async def _startup_event():
|
||||
|
||||
_startup_tasks.append(asyncio.create_task(_startup_mcp_connections()))
|
||||
|
||||
# Pre-warm the RAG tool index off the request path. Loading the local
|
||||
# embedding model + opening ChromaDB + indexing the built-in tools is a
|
||||
# one-time ~1-3s cost that otherwise lands on the user's FIRST message
|
||||
# (showing up as a big `tool_selection` time). Doing it here makes the
|
||||
# first turn as fast as subsequent ones (warm embed ≈ a few ms).
|
||||
async def _warmup_tool_index():
|
||||
try:
|
||||
from src.tool_index import get_tool_index
|
||||
idx = await asyncio.to_thread(get_tool_index)
|
||||
if idx:
|
||||
await asyncio.to_thread(idx.get_tools_for_query, "warmup", 8)
|
||||
logger.info("[startup] Tool index pre-warmed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Tool index warmup failed (non-critical): {type(e).__name__}: {e}")
|
||||
# Startup warmups are opt-in. They make later requests a little warmer, but
|
||||
# they also compete with the first seconds of real UI use on slow or busy
|
||||
# machines. Default to clear/idle startup and let requests warm what they use.
|
||||
_startup_warmups_enabled = str(os.getenv("ODYSSEUS_STARTUP_WARMUPS", "")).lower() in {"1", "true", "yes", "on"}
|
||||
if _startup_warmups_enabled:
|
||||
async def _warmup_tool_index():
|
||||
try:
|
||||
from src.tool_index import get_tool_index
|
||||
idx = await asyncio.to_thread(get_tool_index)
|
||||
if idx:
|
||||
await asyncio.to_thread(idx.get_tools_for_query, "warmup", 8)
|
||||
logger.info("[startup] Tool index pre-warmed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Tool index warmup failed (non-critical): {type(e).__name__}: {e}")
|
||||
|
||||
_startup_tasks.append(asyncio.create_task(_warmup_tool_index()))
|
||||
# Warmup: ping all known LLM endpoints to prime connections
|
||||
async def _warmup_endpoints():
|
||||
try:
|
||||
import httpx
|
||||
# model_discovery has no get_endpoints(); that call raised
|
||||
# AttributeError every run and silently disabled warmup/keepalive.
|
||||
# Resolve the /models probe URLs via the real discovery API, off the
|
||||
# event loop since discovery does a blocking port scan.
|
||||
urls = (
|
||||
await asyncio.to_thread(model_discovery.warmup_ping_urls)
|
||||
if model_discovery else []
|
||||
)
|
||||
for url in urls:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
await client.get(url)
|
||||
logger.info(f"Warmup ping OK: {url}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Warmup ping failed for endpoint: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Warmup ping skipped: {e}")
|
||||
_startup_tasks.append(asyncio.create_task(_warmup_tool_index()))
|
||||
|
||||
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
|
||||
async def _warmup_endpoints():
|
||||
try:
|
||||
import httpx
|
||||
urls = (
|
||||
await asyncio.to_thread(model_discovery.warmup_ping_urls)
|
||||
if model_discovery else []
|
||||
)
|
||||
for url in urls:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
await client.get(url)
|
||||
logger.info(f"Warmup ping OK: {url}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Warmup ping failed for endpoint: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Warmup ping skipped: {e}")
|
||||
|
||||
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
|
||||
else:
|
||||
logger.info("Startup warmups disabled (set ODYSSEUS_STARTUP_WARMUPS=1 to enable)")
|
||||
|
||||
# Keep-alive is opt-in. The ping path performs model discovery, and when
|
||||
# stale LAN endpoints are configured it can add periodic backend pressure
|
||||
|
||||
+1
-1
@@ -472,4 +472,4 @@ All user data lives in `data/` (gitignored): `app.db` (sessions, messages, docum
|
||||
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
|
||||
|
||||
To back up or restore everything in `data/`, see the
|
||||
[Backup & Restore guide](docs/backup-restore.md).
|
||||
[Backup & Restore guide](backup-restore.md).
|
||||
|
||||
+33
-11
@@ -58,6 +58,11 @@ def _uid_fetch_rows(data) -> list:
|
||||
_ACCOUNT_CACHE: dict = {} # key = normalized account selector -> config dict
|
||||
_MCP_OWNER_ARG = "_odysseus_owner"
|
||||
_CURRENT_OWNER: ContextVar[str | None] = ContextVar("email_mcp_owner", default=None)
|
||||
_OWNER_ENV_KEYS = ("ODYSSEUS_MCP_EMAIL_OWNER", "ODYSSEUS_EMAIL_OWNER")
|
||||
_OWNER_SCOPE_ERROR = (
|
||||
"Error: email MCP requires an authenticated owner or ODYSSEUS_MCP_EMAIL_OWNER "
|
||||
"when owner-scoped email accounts are configured."
|
||||
)
|
||||
|
||||
|
||||
def _clean_header_value(value) -> str:
|
||||
@@ -71,13 +76,29 @@ def _db_path() -> Path:
|
||||
return Path(APP_DB)
|
||||
|
||||
|
||||
def _configured_owner() -> str | None:
|
||||
for key in _OWNER_ENV_KEYS:
|
||||
owner = os.environ.get(key, "").strip()
|
||||
if owner:
|
||||
return owner
|
||||
return None
|
||||
|
||||
|
||||
def _current_owner() -> str:
|
||||
owner = _CURRENT_OWNER.get()
|
||||
return str(owner or "").strip()
|
||||
return str(owner or _configured_owner() or "").strip()
|
||||
|
||||
|
||||
def _account_owner(row: dict) -> str:
|
||||
return str(row.get("owner") or "").strip()
|
||||
|
||||
|
||||
def _has_owner_scoped_accounts(rows: list[dict]) -> bool:
|
||||
return any(_account_owner(r) for r in rows)
|
||||
|
||||
|
||||
def _account_visible_to_owner(row: dict, owner: str) -> bool:
|
||||
row_owner = str(row.get("owner") or "").strip()
|
||||
row_owner = _account_owner(row)
|
||||
if row_owner == owner:
|
||||
return True
|
||||
if row_owner:
|
||||
@@ -96,8 +117,7 @@ def _filter_accounts_for_owner(rows: list[dict]) -> list[dict]:
|
||||
if owner:
|
||||
return [r for r in rows if _account_visible_to_owner(r, owner)]
|
||||
|
||||
owners = {str(r.get("owner") or "").strip() for r in rows if str(r.get("owner") or "").strip()}
|
||||
if len(owners) > 1:
|
||||
if _has_owner_scoped_accounts(rows):
|
||||
return []
|
||||
return rows
|
||||
|
||||
@@ -106,8 +126,7 @@ def _mcp_owner_required(rows: list[dict] | None = None) -> bool:
|
||||
if _current_owner():
|
||||
return False
|
||||
rows = rows if rows is not None else _read_accounts_from_db()
|
||||
owners = {str(r.get("owner") or "").strip() for r in rows if str(r.get("owner") or "").strip()}
|
||||
return len(owners) > 1
|
||||
return _has_owner_scoped_accounts(rows)
|
||||
|
||||
|
||||
def _load_email_writing_style() -> str:
|
||||
@@ -274,6 +293,8 @@ def _load_config(account: str | None = None) -> dict:
|
||||
}
|
||||
|
||||
raw_rows = _read_accounts_from_db()
|
||||
if _mcp_owner_required(raw_rows):
|
||||
raise ValueError(_OWNER_SCOPE_ERROR)
|
||||
rows = _filter_accounts_for_owner(raw_rows)
|
||||
row = _resolve_account_from_rows(rows, account)
|
||||
if _current_owner() and raw_rows and not rows:
|
||||
@@ -1193,10 +1214,14 @@ def _send_email(to, subject, body, in_reply_to=None, references=None, cc=None, b
|
||||
UI. This closes the auto-send hole that let earlier models invent
|
||||
signatures and ship them to real recipients without confirmation."""
|
||||
if _read_agent_email_confirm_setting():
|
||||
# Even confirmation-first sends must resolve the selected account now.
|
||||
# Otherwise a caller could stage a pending draft against another
|
||||
# owner's account selector before browser approval handles it.
|
||||
cfg = _load_config(account)
|
||||
return _stash_agent_draft(
|
||||
to=to, subject=subject, body=body,
|
||||
in_reply_to=in_reply_to, references=references,
|
||||
cc=cc, bcc=bcc, account=account,
|
||||
cc=cc, bcc=bcc, account=cfg.get("account_id") or account,
|
||||
)
|
||||
send_account, cfg = _resolve_send_config(account)
|
||||
msg = EmailMessage()
|
||||
@@ -2142,10 +2167,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
try:
|
||||
all_db_accounts = _read_accounts_from_db()
|
||||
if _mcp_owner_required(all_db_accounts):
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text="Error: email MCP requires an authenticated owner when multiple email account owners are configured.",
|
||||
)]
|
||||
return [TextContent(type="text", text=_OWNER_SCOPE_ERROR)]
|
||||
|
||||
if name == "list_email_accounts":
|
||||
rows = _filter_accounts_for_owner(all_db_accounts)
|
||||
|
||||
@@ -14,6 +14,7 @@ from core.database import Session as DBSession, ModelEndpoint
|
||||
from src.llm_core import normalize_model_id
|
||||
from src.endpoint_resolver import normalize_base
|
||||
from src.context_compactor import maybe_compact, trim_for_context
|
||||
from src.model_context import estimate_tokens
|
||||
from src.auth_helpers import effective_user
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from routes.prefs_routes import _load_for_user as load_prefs_for_user
|
||||
@@ -99,6 +100,11 @@ class ChatContext:
|
||||
uprefs: dict
|
||||
preset: PresetInfo
|
||||
preprocessed: PreprocessedMessage
|
||||
context_trimmed: bool = False
|
||||
context_messages_before_trim: int = 0
|
||||
context_messages_after_trim: int = 0
|
||||
context_tokens_before_trim: int = 0
|
||||
context_tokens_after_trim: int = 0
|
||||
# Documents auto-created server-side during preprocess (e.g. when an
|
||||
# attached fillable PDF gets rendered into a markdown editor doc).
|
||||
# The chat route emits a doc_update SSE event for each before streaming
|
||||
@@ -777,7 +783,12 @@ async def build_chat_context(
|
||||
messages, context_length, was_compacted = await maybe_compact(
|
||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
||||
)
|
||||
_before_trim_messages = len(messages)
|
||||
_before_trim_tokens = estimate_tokens(messages)
|
||||
messages = trim_for_context(messages, context_length)
|
||||
_after_trim_messages = len(messages)
|
||||
_after_trim_tokens = estimate_tokens(messages)
|
||||
_context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens
|
||||
|
||||
return ChatContext(
|
||||
preface=preface,
|
||||
@@ -791,6 +802,11 @@ async def build_chat_context(
|
||||
uprefs=uprefs,
|
||||
preset=preset,
|
||||
preprocessed=preprocessed,
|
||||
context_trimmed=_context_trimmed,
|
||||
context_messages_before_trim=_before_trim_messages,
|
||||
context_messages_after_trim=_after_trim_messages,
|
||||
context_tokens_before_trim=_before_trim_tokens,
|
||||
context_tokens_after_trim=_after_trim_tokens,
|
||||
auto_opened_docs=auto_opened_docs,
|
||||
uploaded_files=uploaded_files,
|
||||
)
|
||||
|
||||
+160
-29
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
@@ -40,8 +41,13 @@ from routes.chat_helpers import (
|
||||
clean_thinking_for_save,
|
||||
_enforce_chat_privileges,
|
||||
)
|
||||
from src.action_intents import classify_tool_intent as _classify_tool_intent
|
||||
from src.tool_policy import build_effective_tool_policy
|
||||
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
|
||||
from src.tool_policy import (
|
||||
WEB_TOOL_NAMES,
|
||||
build_effective_tool_policy,
|
||||
is_web_search_explicitly_denied,
|
||||
web_search_enabled_for_turn,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,6 +69,78 @@ def _stream_set(session_id: str, **fields) -> None:
|
||||
rec.update(fields)
|
||||
|
||||
|
||||
def _message_plain_text(content: Any) -> str:
|
||||
if isinstance(content, list):
|
||||
parts: List[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
elif isinstance(block, str):
|
||||
parts.append(block)
|
||||
return " ".join(parts)
|
||||
return str(content or "")
|
||||
|
||||
|
||||
def _last_user_plain_text(messages: List[Dict[str, Any]]) -> str:
|
||||
for msg in reversed(messages or []):
|
||||
if msg.get("role") == "user":
|
||||
return _message_plain_text(msg.get("content"))
|
||||
return ""
|
||||
|
||||
|
||||
def _ensure_current_request_is_latest_user(messages: List[Dict[str, Any]], current_message: str) -> List[Dict[str, Any]]:
|
||||
"""Defensively keep detached streams grounded on the request that created them."""
|
||||
current = str(current_message or "").strip()
|
||||
if not current:
|
||||
return messages
|
||||
latest = _last_user_plain_text(messages).strip()
|
||||
if latest == current or current in latest or latest in current:
|
||||
return messages
|
||||
logger.warning(
|
||||
"[chat_stream] latest user context mismatch; appending current request for model call. latest=%r current=%r",
|
||||
latest[:120],
|
||||
current[:120],
|
||||
)
|
||||
repaired = list(messages or [])
|
||||
repaired.append({"role": "user", "content": current})
|
||||
return repaired
|
||||
|
||||
|
||||
_WEB_FOLLOWUP_RE = re.compile(
|
||||
r"^\s*(?:(?:can|could|would|will)\s+you\s+)?"
|
||||
r"(?:check|try\s+again|look(?:\s+now|\s+it\s+up)?|search(?:\s+now|\s+online|\s+it)?|"
|
||||
r"do\s+it|again)\??\s*$",
|
||||
re.I,
|
||||
)
|
||||
_RECENT_WEB_CONTEXT_RE = re.compile(
|
||||
r"\b(?:weather|forecast|rain|raining|hourly|news|headlines|rate|exchange|currency|"
|
||||
r"price|current|latest|search|look\s+up|online)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _recent_session_text(sess, limit: int = 8, max_chars: int = 2000) -> str:
|
||||
history = getattr(sess, "history", None) or getattr(sess, "_history", None) or []
|
||||
chunks: List[str] = []
|
||||
for msg in history[-limit:]:
|
||||
content = getattr(msg, "content", None)
|
||||
if content is None and isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
text = _message_plain_text(content).strip()
|
||||
if text:
|
||||
chunks.append(text)
|
||||
return " ".join(chunks)[-max_chars:]
|
||||
|
||||
|
||||
def _is_contextual_web_followup(message: str, sess) -> bool:
|
||||
"""Treat short retry/check replies as web lookups when recent context was web."""
|
||||
if not message or not _WEB_FOLLOWUP_RE.search(message):
|
||||
return False
|
||||
return bool(_RECENT_WEB_CONTEXT_RE.search(_recent_session_text(sess)))
|
||||
|
||||
|
||||
def _resolve_request_workspace(request, raw_value) -> tuple:
|
||||
"""Resolve the posted workspace for this request: (workspace, rejected).
|
||||
|
||||
@@ -510,6 +588,7 @@ def setup_chat_routes(
|
||||
# below). Skill extraction should only learn from real agent sessions,
|
||||
# not chats we quietly promoted for a notes/calendar intent.
|
||||
user_requested_agent = (chat_mode == "agent")
|
||||
_search_enabled = web_search_enabled_for_turn(allow_web_search, use_web)
|
||||
# Intent auto-escalation: if the user is clearly asking the assistant
|
||||
# to create a todo, reminder, or calendar event, promote chat → agent
|
||||
# for this turn so the LLM has access to manage_notes / manage_calendar.
|
||||
@@ -527,6 +606,10 @@ def setup_chat_routes(
|
||||
_tool_intent.category,
|
||||
_tool_intent.reason,
|
||||
)
|
||||
elif chat_mode == "chat" and _search_enabled:
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
logger.info("chat→agent auto-escalation: search enabled")
|
||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
|
||||
|
||||
@@ -619,6 +702,20 @@ def setup_chat_routes(
|
||||
400,
|
||||
"No model selected for this chat. Open the model picker and choose one before sending.",
|
||||
)
|
||||
if (
|
||||
chat_mode == "chat"
|
||||
and isinstance(message, str)
|
||||
and (not _tool_intent or not _tool_intent.needs_tools)
|
||||
and _is_contextual_web_followup(message, sess)
|
||||
):
|
||||
_tool_intent = ToolIntent(True, "web", "contextual web lookup follow-up")
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
logger.info(
|
||||
"chat→agent auto-escalation: category=%s reason=%s",
|
||||
_tool_intent.category,
|
||||
_tool_intent.reason,
|
||||
)
|
||||
except SessionNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except (ValueError, ValidationError):
|
||||
@@ -775,20 +872,35 @@ def setup_chat_routes(
|
||||
|
||||
# Build disabled-tools set from frontend toggles + user privileges
|
||||
disabled_tools = set()
|
||||
# Only disable bash/web_search when the caller *explicitly* set them
|
||||
# to a falsy value. When unset (None), defer to per-user privilege
|
||||
# checks below — this lets admins with can_use_bash=True use bash
|
||||
# by default without having to send allow_bash in every request.
|
||||
# Only disable bash when the caller *explicitly* set it to a falsy
|
||||
# value. When unset (None), defer to per-user privilege checks below.
|
||||
# Web search is per-turn opt-in: either the chat pre-search setting
|
||||
# (`use_web=true`) or agent web toggle (`allow_web_search=true`) must
|
||||
# explicitly enable it.
|
||||
if allow_bash is not None and str(allow_bash).lower() != "true":
|
||||
disabled_tools.add("bash")
|
||||
_explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")
|
||||
if (
|
||||
allow_web_search is not None
|
||||
and str(allow_web_search).lower() != "true"
|
||||
and not _explicit_web_intent
|
||||
):
|
||||
disabled_tools.add("web_search")
|
||||
disabled_tools.add("web_fetch")
|
||||
if is_web_search_explicitly_denied(allow_web_search) or not _search_enabled:
|
||||
disabled_tools.update(WEB_TOOL_NAMES)
|
||||
if _explicit_web_intent:
|
||||
# A direct lookup/search request should not drift into personal
|
||||
# tools or shell fallbacks. It can only use web_search/web_fetch
|
||||
# when the request's explicit web setting enabled them.
|
||||
disabled_tools.update({
|
||||
"bash", "python",
|
||||
"search_chats", "manage_skills", "manage_memory",
|
||||
"read_file", "write_file", "edit_file",
|
||||
"create_document", "edit_document", "update_document",
|
||||
"send_email", "reply_to_email",
|
||||
"manage_notes", "manage_calendar", "manage_tasks",
|
||||
"api_call", "builtin_browser",
|
||||
})
|
||||
if _search_enabled:
|
||||
disabled_tools.difference_update(WEB_TOOL_NAMES)
|
||||
else:
|
||||
disabled_tools.update(WEB_TOOL_NAMES)
|
||||
elif _search_enabled:
|
||||
disabled_tools.difference_update(WEB_TOOL_NAMES)
|
||||
|
||||
# Nobody/incognito mode: deny tools that would expose the user's
|
||||
# persistent memory, past chats, or other identity-linked data.
|
||||
@@ -839,11 +951,7 @@ def setup_chat_routes(
|
||||
from src.settings import get_setting
|
||||
_global_disabled = get_setting("disabled_tools", [])
|
||||
if _global_disabled and isinstance(_global_disabled, list):
|
||||
explicit_web_allowed = allow_web_search is not None and str(allow_web_search).lower() == "true"
|
||||
if explicit_web_allowed:
|
||||
disabled_tools.update(t for t in _global_disabled if t not in {"web_search", "web_fetch"})
|
||||
else:
|
||||
disabled_tools.update(_global_disabled)
|
||||
disabled_tools.update(_global_disabled)
|
||||
|
||||
# Light auto-escalation: the user is in chat mode and just expressed a
|
||||
# notes/calendar/email intent. Grant the relevant managers but withhold
|
||||
@@ -1057,13 +1165,16 @@ def setup_chat_routes(
|
||||
_active_streams.pop(session, None)
|
||||
return
|
||||
|
||||
messages = ctx.messages
|
||||
messages = _ensure_current_request_is_latest_user(ctx.messages, message)
|
||||
|
||||
# Auto-compact notification
|
||||
if ctx.was_compacted:
|
||||
yield f"data: {json.dumps({'type': 'compacted', 'context_length': ctx.context_length})}\n\n"
|
||||
if ctx.context_trimmed and not ctx.was_compacted:
|
||||
yield f"data: {json.dumps({'type': 'context_trimmed', 'data': {'context_length': ctx.context_length, 'messages_before': ctx.context_messages_before_trim, 'messages_after': ctx.context_messages_after_trim, 'tokens_before': ctx.context_tokens_before_trim, 'tokens_after': ctx.context_tokens_after_trim}})}\n\n"
|
||||
|
||||
full_response = ""
|
||||
thinking_response = ""
|
||||
last_metrics = None
|
||||
|
||||
# Configured fallback chain for the default chat model. Tried in
|
||||
@@ -1153,7 +1264,9 @@ def setup_chat_routes(
|
||||
# Forward them so the client can show a thinking
|
||||
# indicator, but don't fold them into the saved
|
||||
# reply (mirrors the rewrite path below).
|
||||
if not data.get("thinking"):
|
||||
if data.get("thinking"):
|
||||
thinking_response += data["delta"]
|
||||
else:
|
||||
full_response += data["delta"]
|
||||
_stream_set(session, partial=full_response)
|
||||
yield chunk
|
||||
@@ -1173,6 +1286,12 @@ def setup_chat_routes(
|
||||
_reported_model = last_metrics.get("model")
|
||||
last_metrics["requested_model"] = _requested_model
|
||||
last_metrics["model"] = _reported_model or _actual_model or _answered_by or _requested_model
|
||||
if ctx.context_trimmed:
|
||||
last_metrics["context_trimmed"] = True
|
||||
last_metrics["context_messages_before_trim"] = ctx.context_messages_before_trim
|
||||
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
|
||||
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
||||
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
|
||||
if ctx.context_length and last_metrics.get("input_tokens"):
|
||||
pct = min(round((last_metrics["input_tokens"] / ctx.context_length) * 100, 1), 100.0)
|
||||
last_metrics["context_percent"] = pct
|
||||
@@ -1215,8 +1334,11 @@ def setup_chat_routes(
|
||||
}
|
||||
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
|
||||
if full_response:
|
||||
_metrics_to_save = dict(last_metrics or {})
|
||||
if thinking_response.strip() and not _metrics_to_save.get("thinking"):
|
||||
_metrics_to_save["thinking"] = thinking_response.strip()
|
||||
_saved_id = save_assistant_response(
|
||||
sess, session_manager, session, full_response, last_metrics,
|
||||
sess, session_manager, session, full_response, _metrics_to_save,
|
||||
character_name=ctx.preset.character_name,
|
||||
web_sources=web_sources,
|
||||
rag_sources=ctx.rag_sources,
|
||||
@@ -1229,7 +1351,7 @@ def setup_chat_routes(
|
||||
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
|
||||
run_post_response_tasks(
|
||||
sess, session_manager, session, message, full_response,
|
||||
last_metrics, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
||||
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
||||
incognito=incognito, compare_mode=compare_mode,
|
||||
character_name=ctx.preset.character_name,
|
||||
owner=_user,
|
||||
@@ -1281,8 +1403,8 @@ def setup_chat_routes(
|
||||
_max_rounds = max(1, min(_max_rounds, 200))
|
||||
|
||||
_forced_tools = None
|
||||
if allow_web_search is not None and str(allow_web_search).lower() == "true":
|
||||
_forced_tools = {"web_search", "web_fetch"}
|
||||
if _search_enabled:
|
||||
_forced_tools = set(WEB_TOOL_NAMES)
|
||||
|
||||
async for chunk in stream_agent_loop(
|
||||
sess.endpoint_url,
|
||||
@@ -1315,7 +1437,9 @@ def setup_chat_routes(
|
||||
# Reasoning tokens arrive flagged thinking:true.
|
||||
# Forward them for the live indicator, but keep
|
||||
# them out of the saved reply (same as chat mode).
|
||||
if not data.get("thinking"):
|
||||
if data.get("thinking"):
|
||||
thinking_response += data["delta"]
|
||||
else:
|
||||
full_response += data["delta"]
|
||||
_stream_set(session, partial=full_response)
|
||||
yield chunk
|
||||
@@ -1327,8 +1451,6 @@ def setup_chat_routes(
|
||||
"doc_stream_open", "doc_stream_delta",
|
||||
"doc_update", "doc_suggestions", "ui_control",
|
||||
"rounds_exhausted",
|
||||
"loop_breaker_triggered",
|
||||
"intent_nudge_exhausted",
|
||||
"ask_user",
|
||||
"plan_update",
|
||||
):
|
||||
@@ -1355,6 +1477,12 @@ def setup_chat_routes(
|
||||
_reported_model = last_metrics.get("model")
|
||||
last_metrics["requested_model"] = last_metrics.get("requested_model") or _requested_model
|
||||
last_metrics["model"] = _reported_model or _actual_model or _answered_by or _requested_model
|
||||
if ctx.context_trimmed:
|
||||
last_metrics["context_trimmed"] = True
|
||||
last_metrics["context_messages_before_trim"] = ctx.context_messages_before_trim
|
||||
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
|
||||
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
||||
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
|
||||
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
|
||||
except json.JSONDecodeError:
|
||||
yield chunk
|
||||
@@ -1364,8 +1492,11 @@ def setup_chat_routes(
|
||||
_has_tool_events = bool((last_metrics or {}).get("tool_events"))
|
||||
if full_response or _has_tool_events:
|
||||
_response_to_save = full_response or "Done."
|
||||
_metrics_to_save = dict(last_metrics or {})
|
||||
if thinking_response.strip() and not _metrics_to_save.get("thinking"):
|
||||
_metrics_to_save["thinking"] = thinking_response.strip()
|
||||
_saved_id = save_assistant_response(
|
||||
sess, session_manager, session, _response_to_save, last_metrics,
|
||||
sess, session_manager, session, _response_to_save, _metrics_to_save,
|
||||
character_name=ctx.preset.character_name,
|
||||
web_sources=web_sources,
|
||||
rag_sources=ctx.rag_sources,
|
||||
@@ -1376,7 +1507,7 @@ def setup_chat_routes(
|
||||
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
|
||||
run_post_response_tasks(
|
||||
sess, session_manager, session, message, _response_to_save,
|
||||
last_metrics, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
||||
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
||||
incognito=incognito, compare_mode=compare_mode,
|
||||
character_name=ctx.preset.character_name,
|
||||
agent_rounds=_agent_rounds,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Contacts route domain package (slice 2e, #4082/#4071).
|
||||
|
||||
Contains contacts_routes.py, migrated from the flat routes/ directory.
|
||||
Backward-compat shim at routes/contacts_routes.py re-exports from here.
|
||||
"""
|
||||
@@ -0,0 +1,916 @@
|
||||
"""
|
||||
contacts_routes.py
|
||||
|
||||
CardDAV contacts integration. Reads from local Radicale, supports
|
||||
search and adding new contacts.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
import uuid
|
||||
import json
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import inspect
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin, urlparse, urlunparse
|
||||
|
||||
from core.log_safety import redact_url
|
||||
from fastapi import APIRouter, Query, Depends, Response, HTTPException
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from core.middleware import require_admin
|
||||
from src.url_safety import check_outbound_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from src.constants import DATA_DIR as _DATA_DIR, SETTINGS_FILE as _SETTINGS_FILE, CONTACTS_FILE as _CONTACTS_FILE
|
||||
DATA_DIR = Path(_DATA_DIR)
|
||||
SETTINGS_FILE = Path(_SETTINGS_FILE)
|
||||
LOCAL_CONTACTS_FILE = Path(_CONTACTS_FILE)
|
||||
|
||||
|
||||
def _load_settings():
|
||||
if SETTINGS_FILE.exists():
|
||||
return json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||
return {}
|
||||
|
||||
|
||||
def _save_settings(settings):
|
||||
from core.atomic_io import atomic_write_json
|
||||
atomic_write_json(str(SETTINGS_FILE), settings, indent=2)
|
||||
|
||||
|
||||
def _get_carddav_config():
|
||||
import os
|
||||
settings = _load_settings()
|
||||
password = settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", ""))
|
||||
if password and "carddav_password" in settings:
|
||||
from src.secret_storage import decrypt
|
||||
password = decrypt(password)
|
||||
return {
|
||||
"url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")),
|
||||
"username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")),
|
||||
"password": password,
|
||||
}
|
||||
|
||||
|
||||
def _carddav_configured(cfg: Optional[Dict] = None) -> bool:
|
||||
cfg = cfg or _get_carddav_config()
|
||||
return bool((cfg.get("url") or "").strip())
|
||||
|
||||
|
||||
def _validate_carddav_url(url: str) -> str:
|
||||
cleaned = (url if isinstance(url, str) else "").strip().rstrip("/")
|
||||
ok, reason = check_outbound_url(
|
||||
cleaned,
|
||||
block_private=os.getenv("CARDDAV_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
raise ValueError(f"Rejected CardDAV URL: {reason}")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _carddav_base_url(cfg: Dict) -> str:
|
||||
return _validate_carddav_url(cfg.get("url") or "")
|
||||
|
||||
|
||||
def _normalize_contact(contact: Dict) -> Dict:
|
||||
emails = []
|
||||
for e in contact.get("emails") or ([] if not contact.get("email") else [contact.get("email")]):
|
||||
e = str(e or "").strip()
|
||||
if e and e not in emails:
|
||||
emails.append(e)
|
||||
phones = []
|
||||
for p in contact.get("phones") or ([] if not contact.get("phone") else [contact.get("phone")]):
|
||||
p = str(p or "").strip()
|
||||
if p and p not in phones:
|
||||
phones.append(p)
|
||||
name = str(contact.get("name") or "").strip()
|
||||
if not name and emails:
|
||||
name = emails[0].split("@")[0]
|
||||
address = str(contact.get("address") or "").strip()
|
||||
return {
|
||||
"uid": str(contact.get("uid") or uuid.uuid4()),
|
||||
"name": name,
|
||||
"emails": emails,
|
||||
"phones": phones,
|
||||
"address": address,
|
||||
}
|
||||
|
||||
|
||||
def _load_local_contacts() -> List[Dict]:
|
||||
try:
|
||||
if not LOCAL_CONTACTS_FILE.exists():
|
||||
return []
|
||||
data = json.loads(LOCAL_CONTACTS_FILE.read_text(encoding="utf-8"))
|
||||
rows = data.get("contacts", data) if isinstance(data, dict) else data
|
||||
return [_normalize_contact(c) for c in (rows or []) if isinstance(c, dict)]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load local contacts: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _save_local_contacts(contacts: List[Dict]) -> None:
|
||||
from core.atomic_io import atomic_write_json
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(str(LOCAL_CONTACTS_FILE), {"contacts": [_normalize_contact(c) for c in contacts]}, indent=2)
|
||||
_contact_cache["contacts"] = [_normalize_contact(c) for c in contacts]
|
||||
_contact_cache["fetched_at"] = datetime.utcnow()
|
||||
|
||||
|
||||
# ── vCard parsing ──
|
||||
|
||||
def _vunesc(value: str) -> str:
|
||||
"""Reverse _vesc() — turn escaped vCard text back into the raw value.
|
||||
Order matters: handle \\n/\\, /\\; first, backslash-unescape last."""
|
||||
if not value:
|
||||
return value
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(value):
|
||||
ch = value[i]
|
||||
if ch == "\\" and i + 1 < len(value):
|
||||
nxt = value[i + 1]
|
||||
if nxt in ("n", "N"):
|
||||
out.append("\n")
|
||||
elif nxt in (",", ";", "\\"):
|
||||
out.append(nxt)
|
||||
else:
|
||||
out.append(nxt)
|
||||
i += 2
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _parse_vcards(text: str) -> List[Dict]:
|
||||
"""Parse a stream of vCards into dicts with name, email, phone."""
|
||||
# Unfold RFC 6350 3.2 line folding first: a CRLF/LF followed by a single
|
||||
# space or tab is a continuation of the previous logical line. Real
|
||||
# CardDAV servers (Radicale, iCloud, Apple/Google) fold long EMAIL / FN /
|
||||
# PHOTO lines, and splitting on raw newlines without unfolding dropped the
|
||||
# continuation (e.g. "...@example\n .com" lost the ".com"), truncating the
|
||||
# email/name.
|
||||
text = re.sub(r"\r\n[ \t]", "", text or "")
|
||||
text = re.sub(r"\n[ \t]", "", text)
|
||||
contacts = []
|
||||
for block in re.split(r"BEGIN:VCARD", text):
|
||||
if not block.strip():
|
||||
continue
|
||||
contact = {"name": "", "emails": [], "phones": [], "uid": "", "address": ""}
|
||||
for line in block.split("\n"):
|
||||
line = line.strip()
|
||||
# Strip an optional RFC 6350 group prefix (e.g. "item1.EMAIL;...")
|
||||
# that Apple Contacts / iCloud / many CardDAV servers emit by
|
||||
# default — without this the property-name checks below miss those
|
||||
# lines and silently drop the email / phone. The group token only
|
||||
# precedes the property name, so it is safe to strip for matching
|
||||
# and value extraction, and a no-op for non-grouped lines.
|
||||
name_part = re.sub(r"^[A-Za-z0-9-]+\.", "", line, count=1)
|
||||
if name_part.startswith("FN:") or name_part.startswith("FN;"):
|
||||
contact["name"] = _vunesc(name_part.split(":", 1)[1]) if ":" in name_part else ""
|
||||
elif name_part.startswith("EMAIL"):
|
||||
# Handle EMAIL:foo@bar OR EMAIL;TYPE=...:foo@bar OR EMAIL;PREF=1:foo@bar
|
||||
if ":" in name_part:
|
||||
email_addr = _vunesc(name_part.split(":", 1)[1])
|
||||
if email_addr and email_addr not in contact["emails"]:
|
||||
contact["emails"].append(email_addr)
|
||||
elif name_part.startswith("TEL"):
|
||||
if ":" in name_part:
|
||||
phone = _vunesc(name_part.split(":", 1)[1])
|
||||
if phone and phone not in contact["phones"]:
|
||||
contact["phones"].append(phone)
|
||||
elif name_part.startswith("ADR"):
|
||||
# vCard ADR is 7 semicolon-separated components:
|
||||
# post-office-box;extended-address;street;locality;region;postal-code;country.
|
||||
# Recover a human-readable string by joining non-empty
|
||||
# components with ", ".
|
||||
if ":" in name_part:
|
||||
raw = name_part.split(":", 1)[1]
|
||||
parts = [_vunesc(p).strip() for p in raw.split(";")]
|
||||
contact["address"] = ", ".join(p for p in parts if p)
|
||||
elif name_part.startswith("UID:"):
|
||||
contact["uid"] = _vunesc(name_part[4:])
|
||||
if contact["name"] or contact["emails"]:
|
||||
contacts.append(contact)
|
||||
return contacts
|
||||
|
||||
|
||||
def _vesc(value: str) -> str:
|
||||
"""Escape a vCard property VALUE per RFC 6350 §3.4: backslash, comma,
|
||||
semicolon, and newlines. Without this, a name like 'Sekisui House,Ltd'
|
||||
or any value containing a newline produces a malformed vCard (broken
|
||||
N/FN fields) or could inject arbitrary properties."""
|
||||
return (
|
||||
(value or "")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "")
|
||||
.replace(",", "\\,")
|
||||
.replace(";", "\\;")
|
||||
)
|
||||
|
||||
|
||||
def _build_vcard(name: str, email: str, uid: Optional[str] = None,
|
||||
emails: Optional[List[str]] = None,
|
||||
phones: Optional[List[str]] = None,
|
||||
address: Optional[str] = None) -> str:
|
||||
"""Build a vCard. Accepts either a single `email` (legacy callers) or
|
||||
full `emails`/`phones` lists (edit path). The first email is marked
|
||||
PREF=1. All values are RFC-6350-escaped."""
|
||||
if not uid:
|
||||
uid = str(uuid.uuid4())
|
||||
# Normalize email lists — `email` arg is a convenience for single-email
|
||||
# creation; `emails` (if given) is authoritative.
|
||||
email_list = [e.strip() for e in (emails if emails is not None else ([email] if email else [])) if e and e.strip()]
|
||||
phone_list = [p.strip() for p in (phones or []) if p and p.strip()]
|
||||
# Try to split name into first/last
|
||||
parts = name.strip().split()
|
||||
if len(parts) >= 2:
|
||||
first = parts[0]
|
||||
last = " ".join(parts[1:])
|
||||
else:
|
||||
first = name
|
||||
last = ""
|
||||
# N field is structured (5 components separated by ';') — escape each
|
||||
# component individually so a comma in the name doesn't split it.
|
||||
n_field = f"{_vesc(last)};{_vesc(first)};;;"
|
||||
lines = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
f"UID:{_vesc(uid)}",
|
||||
f"FN:{_vesc(name)}",
|
||||
f"N:{n_field}",
|
||||
]
|
||||
for i, em in enumerate(email_list):
|
||||
# First email is the preferred one.
|
||||
lines.append(f"EMAIL;PREF=1:{_vesc(em)}" if i == 0 else f"EMAIL:{_vesc(em)}")
|
||||
for ph in phone_list:
|
||||
lines.append(f"TEL:{_vesc(ph)}")
|
||||
# Address: stuff the whole human-readable string into the street
|
||||
# component of ADR. vCard ADR has 7 semicolon-separated components:
|
||||
# post-office-box;extended-address;street;locality;region;postal-code;country.
|
||||
addr = (address or "").strip()
|
||||
if addr:
|
||||
lines.append(f"ADR:;;{_vesc(addr)};;;;")
|
||||
lines.append("END:VCARD")
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
|
||||
# ── In-memory cache ──
|
||||
|
||||
_contact_cache = {"contacts": [], "fetched_at": None}
|
||||
|
||||
|
||||
def _abs_url(href: str) -> str:
|
||||
"""Combine a multistatus <href> (an absolute path like
|
||||
/user/contacts/x.vcf) with the configured CardDAV server origin so we
|
||||
get a fully-qualified URL to PUT/DELETE. Absolute hrefs are accepted only
|
||||
for the configured origin; a cross-origin href is treated as a path on the
|
||||
configured server so a malicious CardDAV response cannot redirect later
|
||||
writes/deletes to cloud metadata or another host."""
|
||||
cfg = _get_carddav_config()
|
||||
base = _carddav_base_url(cfg)
|
||||
base_p = urlparse(base)
|
||||
joined = urljoin(base.rstrip("/") + "/", href or "")
|
||||
joined_p = urlparse(joined)
|
||||
if (joined_p.scheme, joined_p.netloc) != (base_p.scheme, base_p.netloc):
|
||||
joined = urlunparse((base_p.scheme, base_p.netloc, joined_p.path or "/", "", joined_p.query, ""))
|
||||
return _validate_carddav_url(joined)
|
||||
|
||||
|
||||
# CardDAV REPORT body — pull every card's etag + raw vCard in ONE request,
|
||||
# alongside the resource href. Lets us map each contact's UID to the real
|
||||
# server resource path (which is NOT always <uid>.vcf for contacts created
|
||||
# by other clients).
|
||||
_ADDRESSBOOK_QUERY = (
|
||||
'<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">'
|
||||
'<D:prop><D:getetag/><C:address-data/></D:prop>'
|
||||
'<C:filter/>'
|
||||
'</C:addressbook-query>'
|
||||
)
|
||||
|
||||
|
||||
def _fetch_via_report(cfg, auth):
|
||||
"""Try a CardDAV REPORT addressbook-query — returns contacts WITH an
|
||||
`href` field, or None if the server doesn't support it / errors."""
|
||||
from defusedxml import ElementTree as ET
|
||||
try:
|
||||
r = httpx.request(
|
||||
"REPORT", cfg["url"],
|
||||
content=_ADDRESSBOOK_QUERY.encode("utf-8"),
|
||||
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"},
|
||||
auth=auth, timeout=10,
|
||||
)
|
||||
if r.status_code not in (207, 200):
|
||||
return None
|
||||
root = ET.fromstring(r.text)
|
||||
ns = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:carddav"}
|
||||
out = []
|
||||
for resp in root.findall("D:response", ns):
|
||||
href_el = resp.find("D:href", ns)
|
||||
data_el = resp.find(".//C:address-data", ns)
|
||||
if href_el is None or data_el is None or not (data_el.text or "").strip():
|
||||
continue
|
||||
parsed = _parse_vcards(data_el.text)
|
||||
if not parsed:
|
||||
continue
|
||||
c = parsed[0]
|
||||
c["href"] = href_el.text.strip()
|
||||
out.append(c)
|
||||
# If the REPORT parsed to ZERO contacts, don't trust it — some
|
||||
# CardDAV servers treat an empty <filter/> as "match nothing" and
|
||||
# return a valid-but-empty 207. Return None so the caller falls
|
||||
# back to the plain GET (which lists everything). A genuinely empty
|
||||
# address book just costs one extra GET that also returns nothing.
|
||||
if not out:
|
||||
return None
|
||||
return out
|
||||
except Exception as e:
|
||||
logger.warning(f"CardDAV REPORT failed, falling back to GET: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_contacts(force=False):
|
||||
"""Fetch all contacts. Uses CardDAV when configured, otherwise local JSON."""
|
||||
if not force and _contact_cache["fetched_at"]:
|
||||
age = (datetime.utcnow() - _contact_cache["fetched_at"]).total_seconds()
|
||||
if age < 60:
|
||||
return _contact_cache["contacts"]
|
||||
|
||||
cfg = _get_carddav_config()
|
||||
if not _carddav_configured(cfg):
|
||||
contacts = _load_local_contacts()
|
||||
_contact_cache["contacts"] = contacts
|
||||
_contact_cache["fetched_at"] = datetime.utcnow()
|
||||
return contacts
|
||||
|
||||
try:
|
||||
cfg["url"] = _carddav_base_url(cfg)
|
||||
auth = None
|
||||
if cfg["username"]:
|
||||
auth = (cfg["username"], cfg["password"])
|
||||
# Preferred path: REPORT gives us hrefs for reliable edit/delete.
|
||||
contacts = _fetch_via_report(cfg, auth)
|
||||
if contacts is None:
|
||||
# Fallback: plain GET, concatenated vCards, no hrefs.
|
||||
r = httpx.get(cfg["url"], auth=auth, timeout=10)
|
||||
if r.status_code != 200:
|
||||
logger.warning(f"CardDAV returned {r.status_code}")
|
||||
return _contact_cache["contacts"]
|
||||
contacts = _parse_vcards(r.text)
|
||||
_contact_cache["contacts"] = contacts
|
||||
_contact_cache["fetched_at"] = datetime.utcnow()
|
||||
return contacts
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch contacts: {e}")
|
||||
return _contact_cache["contacts"]
|
||||
|
||||
|
||||
def _resolve_resource_url(uid: str) -> str:
|
||||
"""Map a contact UID to its real CardDAV resource URL. Uses the href
|
||||
captured during fetch when available (handles contacts whose filename
|
||||
!= UID); falls back to the <uid>.vcf guess for app-created contacts or
|
||||
when no href is known."""
|
||||
def _lookup():
|
||||
for c in _contact_cache.get("contacts", []):
|
||||
if c.get("uid") == uid and c.get("href"):
|
||||
return _abs_url(c["href"])
|
||||
return None
|
||||
found = _lookup()
|
||||
if found:
|
||||
return found
|
||||
# Not in cache (or no href) — refresh once and retry before guessing.
|
||||
try:
|
||||
_fetch_contacts(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
return _lookup() or _vcard_url(uid)
|
||||
|
||||
|
||||
def _create_contact(name: str, email: str = "", address: str = "", phones: Optional[List[str]] = None) -> bool:
|
||||
"""Add a new contact via CardDAV or local contacts."""
|
||||
email = (email or "").strip()
|
||||
phone_list = [str(p or "").strip() for p in (phones or []) if str(p or "").strip()]
|
||||
cfg = _get_carddav_config()
|
||||
if not _carddav_configured(cfg):
|
||||
contacts = _load_local_contacts()
|
||||
email_l = email.lower()
|
||||
for c in contacts:
|
||||
if email_l and email_l in [e.lower() for e in c.get("emails", [])]:
|
||||
return True
|
||||
if phone_list and any(p in (c.get("phones") or []) for p in phone_list):
|
||||
return True
|
||||
contacts.append(_normalize_contact({
|
||||
"name": name,
|
||||
"emails": [email] if email else [],
|
||||
"phones": phone_list,
|
||||
"address": address,
|
||||
}))
|
||||
_save_local_contacts(contacts)
|
||||
return True
|
||||
|
||||
contact_uid = str(uuid.uuid4())
|
||||
vcard = _build_vcard(name, email, contact_uid, address=address, phones=phone_list)
|
||||
try:
|
||||
url = _carddav_base_url(cfg) + "/" + contact_uid + ".vcf"
|
||||
auth = None
|
||||
if cfg["username"]:
|
||||
auth = (cfg["username"], cfg["password"])
|
||||
r = httpx.put(
|
||||
url,
|
||||
data=vcard.encode("utf-8"),
|
||||
headers={"Content-Type": "text/vcard; charset=utf-8"},
|
||||
auth=auth,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code in (200, 201, 204):
|
||||
# Invalidate cache
|
||||
_contact_cache["fetched_at"] = None
|
||||
return True
|
||||
logger.warning(f"CardDAV PUT returned {r.status_code}: {r.text[:200]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create contact: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _vcard_url(uid: str) -> str:
|
||||
"""The CardDAV resource URL for a given contact UID. The uid is URL-
|
||||
encoded so a value containing '/', '..' or other path chars can't
|
||||
escape the collection and target an arbitrary CardDAV resource."""
|
||||
from urllib.parse import quote
|
||||
cfg = _get_carddav_config()
|
||||
return _carddav_base_url(cfg) + "/" + quote(uid, safe="") + ".vcf"
|
||||
|
||||
|
||||
def _import_vcards(text: str) -> Dict:
|
||||
"""Import a (possibly multi-card) .vcf blob. Each card is PUT to the
|
||||
CardDAV server PRESERVING its full original content (ADR/ORG/photo/
|
||||
etc.) — we don't rebuild it, just ensure it has VERSION + UID and
|
||||
normalize line endings. Returns {imported, failed, total}."""
|
||||
from urllib.parse import quote
|
||||
cfg = _get_carddav_config()
|
||||
if not cfg.get("url"):
|
||||
parsed = _parse_vcards(text)
|
||||
contacts = _load_local_contacts()
|
||||
existing = {
|
||||
e.lower()
|
||||
for c in contacts
|
||||
for e in (c.get("emails") or [])
|
||||
if e
|
||||
}
|
||||
imported = 0
|
||||
for c in parsed:
|
||||
emails = [e for e in (c.get("emails") or []) if e]
|
||||
if emails and any(e.lower() in existing for e in emails):
|
||||
continue
|
||||
contacts.append(_normalize_contact(c))
|
||||
for e in emails:
|
||||
existing.add(e.lower())
|
||||
imported += 1
|
||||
if imported:
|
||||
_save_local_contacts(contacts)
|
||||
return {"imported": imported, "failed": 0, "total": len(parsed)}
|
||||
try:
|
||||
base_url = _carddav_base_url(cfg)
|
||||
except ValueError as e:
|
||||
logger.warning("CardDAV import URL rejected: %s", e)
|
||||
return {"imported": 0, "failed": 0, "total": 0, "error": str(e)}
|
||||
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
||||
# Split into individual cards. re.split drops the BEGIN line, so we
|
||||
# re-add it. Normalize CRLF.
|
||||
raw = (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
blocks = []
|
||||
for chunk in raw.split("BEGIN:VCARD"):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
# Trim anything after END:VCARD (defensive).
|
||||
end = chunk.upper().find("END:VCARD")
|
||||
body = chunk[: end + len("END:VCARD")] if end != -1 else chunk
|
||||
blocks.append("BEGIN:VCARD\n" + body)
|
||||
imported = 0
|
||||
failed = 0
|
||||
for block in blocks:
|
||||
# Extract or assign a UID.
|
||||
m = re.search(r"^UID:(.+)$", block, re.MULTILINE)
|
||||
uid = (m.group(1).strip() if m else "") or str(uuid.uuid4())
|
||||
if not m:
|
||||
# Inject a UID right after the VERSION line (or after BEGIN).
|
||||
if re.search(r"^VERSION:", block, re.MULTILINE):
|
||||
block = re.sub(r"(^VERSION:.*$)", r"\1\nUID:" + uid, block, count=1, flags=re.MULTILINE)
|
||||
else:
|
||||
block = block.replace("BEGIN:VCARD", f"BEGIN:VCARD\nVERSION:4.0\nUID:{uid}", 1)
|
||||
elif not re.search(r"^VERSION:", block, re.MULTILINE):
|
||||
block = block.replace("BEGIN:VCARD", "BEGIN:VCARD\nVERSION:4.0", 1)
|
||||
vcard = block.replace("\n", "\r\n") + "\r\n"
|
||||
url = base_url + "/" + quote(uid, safe="") + ".vcf"
|
||||
try:
|
||||
r = httpx.put(
|
||||
url, data=vcard.encode("utf-8"),
|
||||
headers={"Content-Type": "text/vcard; charset=utf-8"},
|
||||
auth=auth, timeout=15,
|
||||
)
|
||||
if r.status_code in (200, 201, 204):
|
||||
imported += 1
|
||||
else:
|
||||
failed += 1
|
||||
logger.warning(f"Import PUT {uid} returned {r.status_code}: {r.text[:120]}")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.error(f"Import PUT {uid} failed: {e}")
|
||||
if imported:
|
||||
_contact_cache["fetched_at"] = None
|
||||
return {"imported": imported, "failed": failed, "total": len(blocks)}
|
||||
|
||||
|
||||
def _import_csv_contacts(text: str) -> Dict:
|
||||
"""Import contacts from CSV. Supports common headers:
|
||||
name/full_name/display_name, email/email_address/e-mail, phone/tel.
|
||||
Falls back to first columns as name,email,phone when no headers exist."""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return {"imported": 0, "failed": 0, "total": 0, "error": "No CSV data found"}
|
||||
|
||||
try:
|
||||
sample = raw[:2048]
|
||||
dialect = csv.Sniffer().sniff(sample)
|
||||
except Exception:
|
||||
dialect = csv.excel
|
||||
|
||||
stream = io.StringIO(raw)
|
||||
try:
|
||||
has_header = csv.Sniffer().has_header(raw[:2048])
|
||||
except Exception:
|
||||
has_header = True
|
||||
|
||||
rows = []
|
||||
if has_header:
|
||||
reader = csv.DictReader(stream, dialect=dialect)
|
||||
for row in reader:
|
||||
lowered = {str(k or "").strip().lower(): (v or "").strip() for k, v in row.items()}
|
||||
name = (
|
||||
lowered.get("name") or lowered.get("full name") or lowered.get("full_name")
|
||||
or lowered.get("display name") or lowered.get("display_name")
|
||||
or lowered.get("fn") or ""
|
||||
)
|
||||
email = (
|
||||
lowered.get("email") or lowered.get("email address")
|
||||
or lowered.get("email_address") or lowered.get("e-mail")
|
||||
or lowered.get("mail") or ""
|
||||
)
|
||||
phone = lowered.get("phone") or lowered.get("telephone") or lowered.get("tel") or ""
|
||||
rows.append((name, email, phone))
|
||||
else:
|
||||
stream.seek(0)
|
||||
reader = csv.reader(stream, dialect=dialect)
|
||||
for row in reader:
|
||||
cols = [(c or "").strip() for c in row]
|
||||
if not any(cols):
|
||||
continue
|
||||
rows.append((
|
||||
cols[0] if len(cols) > 0 else "",
|
||||
cols[1] if len(cols) > 1 else "",
|
||||
cols[2] if len(cols) > 2 else "",
|
||||
))
|
||||
|
||||
imported = 0
|
||||
failed = 0
|
||||
total = 0
|
||||
existing_emails = {
|
||||
e.lower()
|
||||
for c in _fetch_contacts()
|
||||
for e in (c.get("emails") or [])
|
||||
if e
|
||||
}
|
||||
for name, email, phone in rows:
|
||||
email = (email or "").strip()
|
||||
name = (name or "").strip() or (email.split("@")[0] if email else "")
|
||||
if not email:
|
||||
continue
|
||||
total += 1
|
||||
if email.lower() in existing_emails:
|
||||
continue
|
||||
ok = _create_contact(name, email)
|
||||
if ok:
|
||||
imported += 1
|
||||
existing_emails.add(email.lower())
|
||||
# If the CSV had a phone number, rewrite the just-created row
|
||||
# through the richer update path so phone lands in CardDAV too.
|
||||
if phone:
|
||||
try:
|
||||
contacts = _fetch_contacts(force=True)
|
||||
created = next((c for c in contacts if email.lower() in [e.lower() for e in c.get("emails", [])]), None)
|
||||
if created and created.get("uid"):
|
||||
_update_contact(created["uid"], name, [email], [phone])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if imported:
|
||||
_contact_cache["fetched_at"] = None
|
||||
return {"imported": imported, "failed": failed, "total": total}
|
||||
|
||||
|
||||
def _contacts_to_vcf(contacts: List[Dict]) -> str:
|
||||
return "".join(
|
||||
_build_vcard(
|
||||
c.get("name") or ((c.get("emails") or [""])[0].split("@")[0] if c.get("emails") else "Contact"),
|
||||
"",
|
||||
uid=c.get("uid") or str(uuid.uuid4()),
|
||||
emails=c.get("emails") or [],
|
||||
phones=c.get("phones") or [],
|
||||
)
|
||||
for c in contacts
|
||||
)
|
||||
|
||||
|
||||
def _contacts_to_csv(contacts: List[Dict]) -> str:
|
||||
out = io.StringIO()
|
||||
writer = csv.writer(out)
|
||||
writer.writerow(["name", "email", "phone"])
|
||||
for c in contacts:
|
||||
emails = c.get("emails") or [""]
|
||||
phones = c.get("phones") or [""]
|
||||
max_len = max(len(emails), len(phones), 1)
|
||||
for i in range(max_len):
|
||||
writer.writerow([
|
||||
c.get("name") or "",
|
||||
emails[i] if i < len(emails) else "",
|
||||
phones[i] if i < len(phones) else "",
|
||||
])
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _update_contact(uid: str, name: str, emails: List[str], phones: List[str], address: str = "") -> bool:
|
||||
"""Rewrite an existing contact via CardDAV or local contacts."""
|
||||
cfg = _get_carddav_config()
|
||||
if not _carddav_configured(cfg):
|
||||
contacts = _load_local_contacts()
|
||||
found = False
|
||||
out = []
|
||||
for c in contacts:
|
||||
if c.get("uid") == uid:
|
||||
# Preserve existing address when caller passes "" (only
|
||||
# updating name/emails/phones, not touching address).
|
||||
addr = address if address else c.get("address", "")
|
||||
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones, "address": addr}))
|
||||
found = True
|
||||
else:
|
||||
out.append(c)
|
||||
if not found:
|
||||
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones, "address": address}))
|
||||
_save_local_contacts(out)
|
||||
return True
|
||||
|
||||
vcard = _build_vcard(name, "", uid=uid, emails=emails, phones=phones, address=address)
|
||||
# Use the real resource href (handles externally-created contacts whose
|
||||
# filename != UID); falls back to the <uid>.vcf guess.
|
||||
try:
|
||||
url = _resolve_resource_url(uid)
|
||||
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
||||
r = httpx.put(
|
||||
url,
|
||||
data=vcard.encode("utf-8"),
|
||||
headers={"Content-Type": "text/vcard; charset=utf-8"},
|
||||
auth=auth,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code in (200, 201, 204):
|
||||
_contact_cache["fetched_at"] = None
|
||||
return True
|
||||
logger.warning(f"CardDAV update PUT returned {r.status_code}: {r.text[:200]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update contact: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _delete_contact(uid: str) -> bool:
|
||||
"""Delete a contact via CardDAV or local contacts."""
|
||||
cfg = _get_carddav_config()
|
||||
if not _carddav_configured(cfg):
|
||||
contacts = _load_local_contacts()
|
||||
remaining = [c for c in contacts if c.get("uid") != uid]
|
||||
_save_local_contacts(remaining)
|
||||
return True
|
||||
|
||||
try:
|
||||
url = _resolve_resource_url(uid)
|
||||
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
||||
r = httpx.delete(url, auth=auth, timeout=10)
|
||||
if r.status_code in (200, 204, 404):
|
||||
# Invalidate cache so the next fetch sees the server truth.
|
||||
_contact_cache["fetched_at"] = None
|
||||
# Verify: force a fresh fetch and check the UID is actually gone.
|
||||
# A 404 on the guessed URL ({uid}.vcf) can mean the contact
|
||||
# lives at a different resource URL — the DELETE missed it but
|
||||
# we'd silently report success. This check catches that.
|
||||
fresh = _fetch_contacts(force=True)
|
||||
still_there = any(c.get("uid") == uid for c in fresh)
|
||||
if still_there:
|
||||
logger.warning(
|
||||
f"CardDAV DELETE reported success for {uid} "
|
||||
f"but UID still present after re-fetch — "
|
||||
f"resource URL may differ from {redact_url(url)}"
|
||||
)
|
||||
return False
|
||||
if r.status_code == 404:
|
||||
logger.info(f"CardDAV DELETE 404 for {uid} — already gone")
|
||||
return True
|
||||
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete contact: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Routes ──
|
||||
|
||||
def setup_contacts_routes():
|
||||
router = APIRouter(prefix="/api/contacts", tags=["contacts"])
|
||||
|
||||
@router.get("/list")
|
||||
async def list_contacts(_admin: str = Depends(require_admin)):
|
||||
"""List all contacts."""
|
||||
contacts = _fetch_contacts()
|
||||
return {"contacts": contacts, "count": len(contacts)}
|
||||
|
||||
@router.get("/search")
|
||||
async def search_contacts(q: str = Query(""), _admin: str = Depends(require_admin)):
|
||||
"""Search contacts by name or email. Returns up to 10 matches."""
|
||||
contacts = _fetch_contacts()
|
||||
if not q:
|
||||
return {"results": []}
|
||||
q_lower = q.lower()
|
||||
results = []
|
||||
for c in contacts:
|
||||
if q_lower in c["name"].lower():
|
||||
results.append(c)
|
||||
continue
|
||||
for em in c["emails"]:
|
||||
if q_lower in em.lower():
|
||||
results.append(c)
|
||||
break
|
||||
return {"results": results[:10]}
|
||||
|
||||
@router.post("/add")
|
||||
async def add_contact(data: dict, _admin: str = Depends(require_admin)):
|
||||
"""Add a new contact."""
|
||||
name = (data.get("name") or "").strip()
|
||||
email = (data.get("email") or "").strip()
|
||||
phone = (data.get("phone") or "").strip()
|
||||
phones = [str(p or "").strip() for p in (data.get("phones") or []) if str(p or "").strip()]
|
||||
if phone and phone not in phones:
|
||||
phones.insert(0, phone)
|
||||
address = (data.get("address") or "").strip()
|
||||
if not name and email:
|
||||
name = email.split("@")[0]
|
||||
if not name and not email and not phones and not address:
|
||||
return {"success": False, "error": "Name, email, phone, or address required"}
|
||||
if not name:
|
||||
name = email.split("@")[0] if email else (phones[0] if phones else "Contact")
|
||||
contacts = _fetch_contacts()
|
||||
for c in contacts:
|
||||
if email and email.lower() in [e.lower() for e in c.get("emails", [])]:
|
||||
return {"success": True, "message": "Already exists", "contact": c}
|
||||
if phones and any(p in (c.get("phones") or []) for p in phones):
|
||||
return {"success": True, "message": "Already exists", "contact": c}
|
||||
create_params = inspect.signature(_create_contact).parameters
|
||||
if "phones" in create_params:
|
||||
ok = _create_contact(name, email, address, phones=phones)
|
||||
elif len(create_params) >= 3:
|
||||
ok = _create_contact(name, email, address)
|
||||
else:
|
||||
ok = _create_contact(name, email)
|
||||
# If a phone was provided, do an immediate update to thread it
|
||||
# through (the simple _create_contact signature only takes name +
|
||||
# email + address; phones happen via update).
|
||||
if ok and phones and "phones" not in create_params:
|
||||
try:
|
||||
fresh = _fetch_contacts(force=True)
|
||||
created = next((c for c in fresh if name == c.get("name") and (not email or email in c.get("emails", []))), None)
|
||||
if created:
|
||||
_update_contact(
|
||||
created["uid"], name,
|
||||
created.get("emails", []),
|
||||
phones,
|
||||
address,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": ok}
|
||||
|
||||
@router.post("/import")
|
||||
async def import_vcf(data: dict, _admin: str = Depends(require_admin)):
|
||||
"""Import contacts from .vcf or CSV. Body: {"vcf": "..."} or {"csv": "..."}."""
|
||||
# Coerce defensively: a non-string vcf/text/csv (e.g. a number or list
|
||||
# in the JSON body) would otherwise reach .strip() and 500 with an
|
||||
# AttributeError instead of degrading to a clean "no data" response.
|
||||
text = str(data.get("vcf") or data.get("text") or "")
|
||||
csv_text = str(data.get("csv") or "")
|
||||
if text.strip():
|
||||
if "BEGIN:VCARD" not in text.upper():
|
||||
return {"success": False, "error": "No vCard data found"}
|
||||
result = _import_vcards(text)
|
||||
elif csv_text.strip():
|
||||
result = _import_csv_contacts(csv_text)
|
||||
else:
|
||||
return {"success": False, "error": "No contact data found"}
|
||||
result["success"] = result.get("imported", 0) > 0
|
||||
return result
|
||||
|
||||
@router.get("/export")
|
||||
async def export_contacts(
|
||||
format: str = Query("vcf", pattern="^(vcf|csv)$"),
|
||||
_admin: str = Depends(require_admin),
|
||||
):
|
||||
"""Export all contacts as vCard or CSV."""
|
||||
contacts = _fetch_contacts(force=True)
|
||||
if format == "csv":
|
||||
content = _contacts_to_csv(contacts)
|
||||
media_type = "text/csv; charset=utf-8"
|
||||
filename = "odysseus-contacts.csv"
|
||||
else:
|
||||
content = _contacts_to_vcf(contacts)
|
||||
media_type = "text/vcard; charset=utf-8"
|
||||
filename = "odysseus-contacts.vcf"
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config(_admin: str = Depends(require_admin)):
|
||||
cfg = _get_carddav_config()
|
||||
# Mask password
|
||||
if cfg["password"]:
|
||||
cfg["password"] = "***"
|
||||
return cfg
|
||||
|
||||
@router.put("/config")
|
||||
async def update_config(data: dict, _admin: str = Depends(require_admin)):
|
||||
settings = _load_settings()
|
||||
for key in ("carddav_url", "carddav_username", "carddav_password"):
|
||||
if key in data:
|
||||
if key == "carddav_url" and str(data[key] or "").strip():
|
||||
try:
|
||||
settings[key] = _validate_carddav_url(data[key])
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
else:
|
||||
value = data[key]
|
||||
if key == "carddav_password" and value:
|
||||
from src.secret_storage import encrypt
|
||||
value = encrypt(value)
|
||||
settings[key] = value
|
||||
_save_settings(settings)
|
||||
# Force re-fetch
|
||||
_contact_cache["fetched_at"] = None
|
||||
return {"success": True}
|
||||
|
||||
@router.delete("/clear")
|
||||
async def clear_contacts(_admin: str = Depends(require_admin)):
|
||||
"""Clear all local contacts. If CardDAV is configured, only clears the local fallback cache."""
|
||||
_save_local_contacts([])
|
||||
return {"success": True}
|
||||
|
||||
# NOTE: the /{uid} routes are declared LAST so the literal paths above
|
||||
# (/list, /search, /add, /config) win — otherwise PUT /config would
|
||||
# match PUT /{uid} with uid="config".
|
||||
@router.put("/{uid}")
|
||||
async def edit_contact(uid: str, data: dict, _admin: str = Depends(require_admin)):
|
||||
"""Edit an existing contact — name / emails / phones / address."""
|
||||
name = (data.get("name") or "").strip()
|
||||
emails = data.get("emails")
|
||||
phones = data.get("phones")
|
||||
if emails is None and data.get("email"):
|
||||
emails = [data["email"]]
|
||||
emails = [e.strip() for e in (emails or []) if e and e.strip()]
|
||||
phones = [p.strip() for p in (phones or []) if p and p.strip()]
|
||||
address = (data.get("address") or "").strip()
|
||||
if not name and not emails and not address:
|
||||
return {"success": False, "error": "Name, email, or address required"}
|
||||
if not name and emails:
|
||||
name = emails[0].split("@")[0]
|
||||
ok = _update_contact(uid, name, emails, phones, address)
|
||||
return {"success": ok}
|
||||
|
||||
@router.delete("/{uid}")
|
||||
async def delete_contact(uid: str, _admin: str = Depends(require_admin)):
|
||||
"""Delete a contact by UID."""
|
||||
if not uid:
|
||||
return {"success": False, "error": "UID required"}
|
||||
ok = _delete_contact(uid)
|
||||
return {"success": ok}
|
||||
|
||||
return router
|
||||
+8
-895
@@ -1,900 +1,13 @@
|
||||
"""
|
||||
contacts_routes.py
|
||||
"""Backward-compat shim — canonical location is routes/contacts/contacts_routes.py.
|
||||
|
||||
CardDAV contacts integration. Reads from local Radicale, supports
|
||||
search and adding new contacts.
|
||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||
that ``import routes.contacts_routes``, ``from routes.contacts_routes import X``,
|
||||
``importlib.import_module("routes.contacts_routes")``, and string-targeted
|
||||
monkeypatches all operate on the same object the application actually uses.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
import uuid
|
||||
import json
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import inspect
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin, urlparse, urlunparse
|
||||
import sys as _sys
|
||||
|
||||
from core.log_safety import redact_url
|
||||
from fastapi import APIRouter, Query, Depends, Response, HTTPException
|
||||
from typing import List, Dict, Optional
|
||||
from routes.contacts import contacts_routes as _canonical # noqa: F401
|
||||
|
||||
from core.middleware import require_admin
|
||||
from src.url_safety import check_outbound_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from src.constants import DATA_DIR as _DATA_DIR, SETTINGS_FILE as _SETTINGS_FILE, CONTACTS_FILE as _CONTACTS_FILE
|
||||
DATA_DIR = Path(_DATA_DIR)
|
||||
SETTINGS_FILE = Path(_SETTINGS_FILE)
|
||||
LOCAL_CONTACTS_FILE = Path(_CONTACTS_FILE)
|
||||
|
||||
|
||||
def _load_settings():
|
||||
if SETTINGS_FILE.exists():
|
||||
return json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||
return {}
|
||||
|
||||
|
||||
def _save_settings(settings):
|
||||
from core.atomic_io import atomic_write_json
|
||||
atomic_write_json(str(SETTINGS_FILE), settings, indent=2)
|
||||
|
||||
|
||||
def _get_carddav_config():
|
||||
import os
|
||||
settings = _load_settings()
|
||||
password = settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", ""))
|
||||
if password and "carddav_password" in settings:
|
||||
from src.secret_storage import decrypt
|
||||
password = decrypt(password)
|
||||
return {
|
||||
"url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")),
|
||||
"username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")),
|
||||
"password": password,
|
||||
}
|
||||
|
||||
|
||||
def _carddav_configured(cfg: Optional[Dict] = None) -> bool:
|
||||
cfg = cfg or _get_carddav_config()
|
||||
return bool((cfg.get("url") or "").strip())
|
||||
|
||||
|
||||
def _validate_carddav_url(url: str) -> str:
|
||||
cleaned = (url if isinstance(url, str) else "").strip().rstrip("/")
|
||||
ok, reason = check_outbound_url(
|
||||
cleaned,
|
||||
block_private=os.getenv("CARDDAV_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
raise ValueError(f"Rejected CardDAV URL: {reason}")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _carddav_base_url(cfg: Dict) -> str:
|
||||
return _validate_carddav_url(cfg.get("url") or "")
|
||||
|
||||
|
||||
def _normalize_contact(contact: Dict) -> Dict:
|
||||
emails = []
|
||||
for e in contact.get("emails") or ([] if not contact.get("email") else [contact.get("email")]):
|
||||
e = str(e or "").strip()
|
||||
if e and e not in emails:
|
||||
emails.append(e)
|
||||
phones = []
|
||||
for p in contact.get("phones") or ([] if not contact.get("phone") else [contact.get("phone")]):
|
||||
p = str(p or "").strip()
|
||||
if p and p not in phones:
|
||||
phones.append(p)
|
||||
name = str(contact.get("name") or "").strip()
|
||||
if not name and emails:
|
||||
name = emails[0].split("@")[0]
|
||||
address = str(contact.get("address") or "").strip()
|
||||
return {
|
||||
"uid": str(contact.get("uid") or uuid.uuid4()),
|
||||
"name": name,
|
||||
"emails": emails,
|
||||
"phones": phones,
|
||||
"address": address,
|
||||
}
|
||||
|
||||
|
||||
def _load_local_contacts() -> List[Dict]:
|
||||
try:
|
||||
if not LOCAL_CONTACTS_FILE.exists():
|
||||
return []
|
||||
data = json.loads(LOCAL_CONTACTS_FILE.read_text(encoding="utf-8"))
|
||||
rows = data.get("contacts", data) if isinstance(data, dict) else data
|
||||
return [_normalize_contact(c) for c in (rows or []) if isinstance(c, dict)]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load local contacts: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _save_local_contacts(contacts: List[Dict]) -> None:
|
||||
from core.atomic_io import atomic_write_json
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(str(LOCAL_CONTACTS_FILE), {"contacts": [_normalize_contact(c) for c in contacts]}, indent=2)
|
||||
_contact_cache["contacts"] = [_normalize_contact(c) for c in contacts]
|
||||
_contact_cache["fetched_at"] = datetime.utcnow()
|
||||
|
||||
|
||||
# ── vCard parsing ──
|
||||
|
||||
def _vunesc(value: str) -> str:
|
||||
"""Reverse _vesc() — turn escaped vCard text back into the raw value.
|
||||
Order matters: handle \\n/\\, /\\; first, backslash-unescape last."""
|
||||
if not value:
|
||||
return value
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(value):
|
||||
ch = value[i]
|
||||
if ch == "\\" and i + 1 < len(value):
|
||||
nxt = value[i + 1]
|
||||
if nxt in ("n", "N"):
|
||||
out.append("\n")
|
||||
elif nxt in (",", ";", "\\"):
|
||||
out.append(nxt)
|
||||
else:
|
||||
out.append(nxt)
|
||||
i += 2
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _parse_vcards(text: str) -> List[Dict]:
|
||||
"""Parse a stream of vCards into dicts with name, email, phone."""
|
||||
# Unfold RFC 6350 3.2 line folding first: a CRLF/LF followed by a single
|
||||
# space or tab is a continuation of the previous logical line. Real
|
||||
# CardDAV servers (Radicale, iCloud, Apple/Google) fold long EMAIL / FN /
|
||||
# PHOTO lines, and splitting on raw newlines without unfolding dropped the
|
||||
# continuation (e.g. "...@example\n .com" lost the ".com"), truncating the
|
||||
# email/name.
|
||||
text = re.sub(r"\r\n[ \t]", "", text or "")
|
||||
text = re.sub(r"\n[ \t]", "", text)
|
||||
contacts = []
|
||||
for block in re.split(r"BEGIN:VCARD", text):
|
||||
if not block.strip():
|
||||
continue
|
||||
contact = {"name": "", "emails": [], "phones": [], "uid": "", "address": ""}
|
||||
for line in block.split("\n"):
|
||||
line = line.strip()
|
||||
# Strip an optional RFC 6350 group prefix (e.g. "item1.EMAIL;...")
|
||||
# that Apple Contacts / iCloud / many CardDAV servers emit by
|
||||
# default — without this the property-name checks below miss those
|
||||
# lines and silently drop the email / phone. The group token only
|
||||
# precedes the property name, so it is safe to strip for matching
|
||||
# and value extraction, and a no-op for non-grouped lines.
|
||||
name_part = re.sub(r"^[A-Za-z0-9-]+\.", "", line, count=1)
|
||||
if name_part.startswith("FN:") or name_part.startswith("FN;"):
|
||||
contact["name"] = _vunesc(name_part.split(":", 1)[1]) if ":" in name_part else ""
|
||||
elif name_part.startswith("EMAIL"):
|
||||
# Handle EMAIL:foo@bar OR EMAIL;TYPE=...:foo@bar OR EMAIL;PREF=1:foo@bar
|
||||
if ":" in name_part:
|
||||
email_addr = _vunesc(name_part.split(":", 1)[1])
|
||||
if email_addr and email_addr not in contact["emails"]:
|
||||
contact["emails"].append(email_addr)
|
||||
elif name_part.startswith("TEL"):
|
||||
if ":" in name_part:
|
||||
phone = _vunesc(name_part.split(":", 1)[1])
|
||||
if phone and phone not in contact["phones"]:
|
||||
contact["phones"].append(phone)
|
||||
elif name_part.startswith("ADR"):
|
||||
# vCard ADR is 7 semicolon-separated components:
|
||||
# post-office-box;extended-address;street;locality;region;postal-code;country.
|
||||
# Recover a human-readable string by joining non-empty
|
||||
# components with ", ".
|
||||
if ":" in name_part:
|
||||
raw = name_part.split(":", 1)[1]
|
||||
parts = [_vunesc(p).strip() for p in raw.split(";")]
|
||||
contact["address"] = ", ".join(p for p in parts if p)
|
||||
elif name_part.startswith("UID:"):
|
||||
contact["uid"] = _vunesc(name_part[4:])
|
||||
if contact["name"] or contact["emails"]:
|
||||
contacts.append(contact)
|
||||
return contacts
|
||||
|
||||
|
||||
def _vesc(value: str) -> str:
|
||||
"""Escape a vCard property VALUE per RFC 6350 §3.4: backslash, comma,
|
||||
semicolon, and newlines. Without this, a name like 'Sekisui House,Ltd'
|
||||
or any value containing a newline produces a malformed vCard (broken
|
||||
N/FN fields) or could inject arbitrary properties."""
|
||||
return (
|
||||
(value or "")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "")
|
||||
.replace(",", "\\,")
|
||||
.replace(";", "\\;")
|
||||
)
|
||||
|
||||
|
||||
def _build_vcard(name: str, email: str, uid: Optional[str] = None,
|
||||
emails: Optional[List[str]] = None,
|
||||
phones: Optional[List[str]] = None,
|
||||
address: Optional[str] = None) -> str:
|
||||
"""Build a vCard. Accepts either a single `email` (legacy callers) or
|
||||
full `emails`/`phones` lists (edit path). The first email is marked
|
||||
PREF=1. All values are RFC-6350-escaped."""
|
||||
if not uid:
|
||||
uid = str(uuid.uuid4())
|
||||
# Normalize email lists — `email` arg is a convenience for single-email
|
||||
# creation; `emails` (if given) is authoritative.
|
||||
email_list = [e.strip() for e in (emails if emails is not None else ([email] if email else [])) if e and e.strip()]
|
||||
phone_list = [p.strip() for p in (phones or []) if p and p.strip()]
|
||||
# Try to split name into first/last
|
||||
parts = name.strip().split()
|
||||
if len(parts) >= 2:
|
||||
first = parts[0]
|
||||
last = " ".join(parts[1:])
|
||||
else:
|
||||
first = name
|
||||
last = ""
|
||||
# N field is structured (5 components separated by ';') — escape each
|
||||
# component individually so a comma in the name doesn't split it.
|
||||
n_field = f"{_vesc(last)};{_vesc(first)};;;"
|
||||
lines = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
f"UID:{_vesc(uid)}",
|
||||
f"FN:{_vesc(name)}",
|
||||
f"N:{n_field}",
|
||||
]
|
||||
for i, em in enumerate(email_list):
|
||||
# First email is the preferred one.
|
||||
lines.append(f"EMAIL;PREF=1:{_vesc(em)}" if i == 0 else f"EMAIL:{_vesc(em)}")
|
||||
for ph in phone_list:
|
||||
lines.append(f"TEL:{_vesc(ph)}")
|
||||
# Address: stuff the whole human-readable string into the street
|
||||
# component of ADR. vCard ADR has 7 semicolon-separated components:
|
||||
# post-office-box;extended-address;street;locality;region;postal-code;country.
|
||||
addr = (address or "").strip()
|
||||
if addr:
|
||||
lines.append(f"ADR:;;{_vesc(addr)};;;;")
|
||||
lines.append("END:VCARD")
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
|
||||
# ── In-memory cache ──
|
||||
|
||||
_contact_cache = {"contacts": [], "fetched_at": None}
|
||||
|
||||
|
||||
def _abs_url(href: str) -> str:
|
||||
"""Combine a multistatus <href> (an absolute path like
|
||||
/user/contacts/x.vcf) with the configured CardDAV server origin so we
|
||||
get a fully-qualified URL to PUT/DELETE. Absolute hrefs are accepted only
|
||||
for the configured origin; a cross-origin href is treated as a path on the
|
||||
configured server so a malicious CardDAV response cannot redirect later
|
||||
writes/deletes to cloud metadata or another host."""
|
||||
cfg = _get_carddav_config()
|
||||
base = _carddav_base_url(cfg)
|
||||
base_p = urlparse(base)
|
||||
joined = urljoin(base.rstrip("/") + "/", href or "")
|
||||
joined_p = urlparse(joined)
|
||||
if (joined_p.scheme, joined_p.netloc) != (base_p.scheme, base_p.netloc):
|
||||
joined = urlunparse((base_p.scheme, base_p.netloc, joined_p.path or "/", "", joined_p.query, ""))
|
||||
return _validate_carddav_url(joined)
|
||||
|
||||
|
||||
# CardDAV REPORT body — pull every card's etag + raw vCard in ONE request,
|
||||
# alongside the resource href. Lets us map each contact's UID to the real
|
||||
# server resource path (which is NOT always <uid>.vcf for contacts created
|
||||
# by other clients).
|
||||
_ADDRESSBOOK_QUERY = (
|
||||
'<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">'
|
||||
'<D:prop><D:getetag/><C:address-data/></D:prop>'
|
||||
'<C:filter/>'
|
||||
'</C:addressbook-query>'
|
||||
)
|
||||
|
||||
|
||||
def _fetch_via_report(cfg, auth):
|
||||
"""Try a CardDAV REPORT addressbook-query — returns contacts WITH an
|
||||
`href` field, or None if the server doesn't support it / errors."""
|
||||
from defusedxml import ElementTree as ET
|
||||
try:
|
||||
r = httpx.request(
|
||||
"REPORT", cfg["url"],
|
||||
content=_ADDRESSBOOK_QUERY.encode("utf-8"),
|
||||
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"},
|
||||
auth=auth, timeout=10,
|
||||
)
|
||||
if r.status_code not in (207, 200):
|
||||
return None
|
||||
root = ET.fromstring(r.text)
|
||||
ns = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:carddav"}
|
||||
out = []
|
||||
for resp in root.findall("D:response", ns):
|
||||
href_el = resp.find("D:href", ns)
|
||||
data_el = resp.find(".//C:address-data", ns)
|
||||
if href_el is None or data_el is None or not (data_el.text or "").strip():
|
||||
continue
|
||||
parsed = _parse_vcards(data_el.text)
|
||||
if not parsed:
|
||||
continue
|
||||
c = parsed[0]
|
||||
c["href"] = href_el.text.strip()
|
||||
out.append(c)
|
||||
# If the REPORT parsed to ZERO contacts, don't trust it — some
|
||||
# CardDAV servers treat an empty <filter/> as "match nothing" and
|
||||
# return a valid-but-empty 207. Return None so the caller falls
|
||||
# back to the plain GET (which lists everything). A genuinely empty
|
||||
# address book just costs one extra GET that also returns nothing.
|
||||
if not out:
|
||||
return None
|
||||
return out
|
||||
except Exception as e:
|
||||
logger.warning(f"CardDAV REPORT failed, falling back to GET: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_contacts(force=False):
|
||||
"""Fetch all contacts. Uses CardDAV when configured, otherwise local JSON."""
|
||||
if not force and _contact_cache["fetched_at"]:
|
||||
age = (datetime.utcnow() - _contact_cache["fetched_at"]).total_seconds()
|
||||
if age < 60:
|
||||
return _contact_cache["contacts"]
|
||||
|
||||
cfg = _get_carddav_config()
|
||||
if not _carddav_configured(cfg):
|
||||
contacts = _load_local_contacts()
|
||||
_contact_cache["contacts"] = contacts
|
||||
_contact_cache["fetched_at"] = datetime.utcnow()
|
||||
return contacts
|
||||
|
||||
try:
|
||||
cfg["url"] = _carddav_base_url(cfg)
|
||||
auth = None
|
||||
if cfg["username"]:
|
||||
auth = (cfg["username"], cfg["password"])
|
||||
# Preferred path: REPORT gives us hrefs for reliable edit/delete.
|
||||
contacts = _fetch_via_report(cfg, auth)
|
||||
if contacts is None:
|
||||
# Fallback: plain GET, concatenated vCards, no hrefs.
|
||||
r = httpx.get(cfg["url"], auth=auth, timeout=10)
|
||||
if r.status_code != 200:
|
||||
logger.warning(f"CardDAV returned {r.status_code}")
|
||||
return _contact_cache["contacts"]
|
||||
contacts = _parse_vcards(r.text)
|
||||
_contact_cache["contacts"] = contacts
|
||||
_contact_cache["fetched_at"] = datetime.utcnow()
|
||||
return contacts
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch contacts: {e}")
|
||||
return _contact_cache["contacts"]
|
||||
|
||||
|
||||
def _resolve_resource_url(uid: str) -> str:
|
||||
"""Map a contact UID to its real CardDAV resource URL. Uses the href
|
||||
captured during fetch when available (handles contacts whose filename
|
||||
!= UID); falls back to the <uid>.vcf guess for app-created contacts or
|
||||
when no href is known."""
|
||||
def _lookup():
|
||||
for c in _contact_cache.get("contacts", []):
|
||||
if c.get("uid") == uid and c.get("href"):
|
||||
return _abs_url(c["href"])
|
||||
return None
|
||||
found = _lookup()
|
||||
if found:
|
||||
return found
|
||||
# Not in cache (or no href) — refresh once and retry before guessing.
|
||||
try:
|
||||
_fetch_contacts(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
return _lookup() or _vcard_url(uid)
|
||||
|
||||
|
||||
def _create_contact(name: str, email: str, address: str = "") -> bool:
|
||||
"""Add a new contact via CardDAV or local contacts."""
|
||||
cfg = _get_carddav_config()
|
||||
if not _carddav_configured(cfg):
|
||||
contacts = _load_local_contacts()
|
||||
email_l = (email or "").strip().lower()
|
||||
for c in contacts:
|
||||
if email_l and email_l in [e.lower() for e in c.get("emails", [])]:
|
||||
return True
|
||||
contacts.append(_normalize_contact({"name": name, "emails": [email], "address": address}))
|
||||
_save_local_contacts(contacts)
|
||||
return True
|
||||
|
||||
contact_uid = str(uuid.uuid4())
|
||||
vcard = _build_vcard(name, email, contact_uid, address=address)
|
||||
try:
|
||||
url = _carddav_base_url(cfg) + "/" + contact_uid + ".vcf"
|
||||
auth = None
|
||||
if cfg["username"]:
|
||||
auth = (cfg["username"], cfg["password"])
|
||||
r = httpx.put(
|
||||
url,
|
||||
data=vcard.encode("utf-8"),
|
||||
headers={"Content-Type": "text/vcard; charset=utf-8"},
|
||||
auth=auth,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code in (200, 201, 204):
|
||||
# Invalidate cache
|
||||
_contact_cache["fetched_at"] = None
|
||||
return True
|
||||
logger.warning(f"CardDAV PUT returned {r.status_code}: {r.text[:200]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create contact: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _vcard_url(uid: str) -> str:
|
||||
"""The CardDAV resource URL for a given contact UID. The uid is URL-
|
||||
encoded so a value containing '/', '..' or other path chars can't
|
||||
escape the collection and target an arbitrary CardDAV resource."""
|
||||
from urllib.parse import quote
|
||||
cfg = _get_carddav_config()
|
||||
return _carddav_base_url(cfg) + "/" + quote(uid, safe="") + ".vcf"
|
||||
|
||||
|
||||
def _import_vcards(text: str) -> Dict:
|
||||
"""Import a (possibly multi-card) .vcf blob. Each card is PUT to the
|
||||
CardDAV server PRESERVING its full original content (ADR/ORG/photo/
|
||||
etc.) — we don't rebuild it, just ensure it has VERSION + UID and
|
||||
normalize line endings. Returns {imported, failed, total}."""
|
||||
from urllib.parse import quote
|
||||
cfg = _get_carddav_config()
|
||||
if not cfg.get("url"):
|
||||
parsed = _parse_vcards(text)
|
||||
contacts = _load_local_contacts()
|
||||
existing = {
|
||||
e.lower()
|
||||
for c in contacts
|
||||
for e in (c.get("emails") or [])
|
||||
if e
|
||||
}
|
||||
imported = 0
|
||||
for c in parsed:
|
||||
emails = [e for e in (c.get("emails") or []) if e]
|
||||
if emails and any(e.lower() in existing for e in emails):
|
||||
continue
|
||||
contacts.append(_normalize_contact(c))
|
||||
for e in emails:
|
||||
existing.add(e.lower())
|
||||
imported += 1
|
||||
if imported:
|
||||
_save_local_contacts(contacts)
|
||||
return {"imported": imported, "failed": 0, "total": len(parsed)}
|
||||
try:
|
||||
base_url = _carddav_base_url(cfg)
|
||||
except ValueError as e:
|
||||
logger.warning("CardDAV import URL rejected: %s", e)
|
||||
return {"imported": 0, "failed": 0, "total": 0, "error": str(e)}
|
||||
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
||||
# Split into individual cards. re.split drops the BEGIN line, so we
|
||||
# re-add it. Normalize CRLF.
|
||||
raw = (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
blocks = []
|
||||
for chunk in raw.split("BEGIN:VCARD"):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
# Trim anything after END:VCARD (defensive).
|
||||
end = chunk.upper().find("END:VCARD")
|
||||
body = chunk[: end + len("END:VCARD")] if end != -1 else chunk
|
||||
blocks.append("BEGIN:VCARD\n" + body)
|
||||
imported = 0
|
||||
failed = 0
|
||||
for block in blocks:
|
||||
# Extract or assign a UID.
|
||||
m = re.search(r"^UID:(.+)$", block, re.MULTILINE)
|
||||
uid = (m.group(1).strip() if m else "") or str(uuid.uuid4())
|
||||
if not m:
|
||||
# Inject a UID right after the VERSION line (or after BEGIN).
|
||||
if re.search(r"^VERSION:", block, re.MULTILINE):
|
||||
block = re.sub(r"(^VERSION:.*$)", r"\1\nUID:" + uid, block, count=1, flags=re.MULTILINE)
|
||||
else:
|
||||
block = block.replace("BEGIN:VCARD", f"BEGIN:VCARD\nVERSION:4.0\nUID:{uid}", 1)
|
||||
elif not re.search(r"^VERSION:", block, re.MULTILINE):
|
||||
block = block.replace("BEGIN:VCARD", "BEGIN:VCARD\nVERSION:4.0", 1)
|
||||
vcard = block.replace("\n", "\r\n") + "\r\n"
|
||||
url = base_url + "/" + quote(uid, safe="") + ".vcf"
|
||||
try:
|
||||
r = httpx.put(
|
||||
url, data=vcard.encode("utf-8"),
|
||||
headers={"Content-Type": "text/vcard; charset=utf-8"},
|
||||
auth=auth, timeout=15,
|
||||
)
|
||||
if r.status_code in (200, 201, 204):
|
||||
imported += 1
|
||||
else:
|
||||
failed += 1
|
||||
logger.warning(f"Import PUT {uid} returned {r.status_code}: {r.text[:120]}")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.error(f"Import PUT {uid} failed: {e}")
|
||||
if imported:
|
||||
_contact_cache["fetched_at"] = None
|
||||
return {"imported": imported, "failed": failed, "total": len(blocks)}
|
||||
|
||||
|
||||
def _import_csv_contacts(text: str) -> Dict:
|
||||
"""Import contacts from CSV. Supports common headers:
|
||||
name/full_name/display_name, email/email_address/e-mail, phone/tel.
|
||||
Falls back to first columns as name,email,phone when no headers exist."""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return {"imported": 0, "failed": 0, "total": 0, "error": "No CSV data found"}
|
||||
|
||||
try:
|
||||
sample = raw[:2048]
|
||||
dialect = csv.Sniffer().sniff(sample)
|
||||
except Exception:
|
||||
dialect = csv.excel
|
||||
|
||||
stream = io.StringIO(raw)
|
||||
try:
|
||||
has_header = csv.Sniffer().has_header(raw[:2048])
|
||||
except Exception:
|
||||
has_header = True
|
||||
|
||||
rows = []
|
||||
if has_header:
|
||||
reader = csv.DictReader(stream, dialect=dialect)
|
||||
for row in reader:
|
||||
lowered = {str(k or "").strip().lower(): (v or "").strip() for k, v in row.items()}
|
||||
name = (
|
||||
lowered.get("name") or lowered.get("full name") or lowered.get("full_name")
|
||||
or lowered.get("display name") or lowered.get("display_name")
|
||||
or lowered.get("fn") or ""
|
||||
)
|
||||
email = (
|
||||
lowered.get("email") or lowered.get("email address")
|
||||
or lowered.get("email_address") or lowered.get("e-mail")
|
||||
or lowered.get("mail") or ""
|
||||
)
|
||||
phone = lowered.get("phone") or lowered.get("telephone") or lowered.get("tel") or ""
|
||||
rows.append((name, email, phone))
|
||||
else:
|
||||
stream.seek(0)
|
||||
reader = csv.reader(stream, dialect=dialect)
|
||||
for row in reader:
|
||||
cols = [(c or "").strip() for c in row]
|
||||
if not any(cols):
|
||||
continue
|
||||
rows.append((
|
||||
cols[0] if len(cols) > 0 else "",
|
||||
cols[1] if len(cols) > 1 else "",
|
||||
cols[2] if len(cols) > 2 else "",
|
||||
))
|
||||
|
||||
imported = 0
|
||||
failed = 0
|
||||
total = 0
|
||||
existing_emails = {
|
||||
e.lower()
|
||||
for c in _fetch_contacts()
|
||||
for e in (c.get("emails") or [])
|
||||
if e
|
||||
}
|
||||
for name, email, phone in rows:
|
||||
email = (email or "").strip()
|
||||
name = (name or "").strip() or (email.split("@")[0] if email else "")
|
||||
if not email:
|
||||
continue
|
||||
total += 1
|
||||
if email.lower() in existing_emails:
|
||||
continue
|
||||
ok = _create_contact(name, email)
|
||||
if ok:
|
||||
imported += 1
|
||||
existing_emails.add(email.lower())
|
||||
# If the CSV had a phone number, rewrite the just-created row
|
||||
# through the richer update path so phone lands in CardDAV too.
|
||||
if phone:
|
||||
try:
|
||||
contacts = _fetch_contacts(force=True)
|
||||
created = next((c for c in contacts if email.lower() in [e.lower() for e in c.get("emails", [])]), None)
|
||||
if created and created.get("uid"):
|
||||
_update_contact(created["uid"], name, [email], [phone])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if imported:
|
||||
_contact_cache["fetched_at"] = None
|
||||
return {"imported": imported, "failed": failed, "total": total}
|
||||
|
||||
|
||||
def _contacts_to_vcf(contacts: List[Dict]) -> str:
|
||||
return "".join(
|
||||
_build_vcard(
|
||||
c.get("name") or ((c.get("emails") or [""])[0].split("@")[0] if c.get("emails") else "Contact"),
|
||||
"",
|
||||
uid=c.get("uid") or str(uuid.uuid4()),
|
||||
emails=c.get("emails") or [],
|
||||
phones=c.get("phones") or [],
|
||||
)
|
||||
for c in contacts
|
||||
)
|
||||
|
||||
|
||||
def _contacts_to_csv(contacts: List[Dict]) -> str:
|
||||
out = io.StringIO()
|
||||
writer = csv.writer(out)
|
||||
writer.writerow(["name", "email", "phone"])
|
||||
for c in contacts:
|
||||
emails = c.get("emails") or [""]
|
||||
phones = c.get("phones") or [""]
|
||||
max_len = max(len(emails), len(phones), 1)
|
||||
for i in range(max_len):
|
||||
writer.writerow([
|
||||
c.get("name") or "",
|
||||
emails[i] if i < len(emails) else "",
|
||||
phones[i] if i < len(phones) else "",
|
||||
])
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _update_contact(uid: str, name: str, emails: List[str], phones: List[str], address: str = "") -> bool:
|
||||
"""Rewrite an existing contact via CardDAV or local contacts."""
|
||||
cfg = _get_carddav_config()
|
||||
if not _carddav_configured(cfg):
|
||||
contacts = _load_local_contacts()
|
||||
found = False
|
||||
out = []
|
||||
for c in contacts:
|
||||
if c.get("uid") == uid:
|
||||
# Preserve existing address when caller passes "" (only
|
||||
# updating name/emails/phones, not touching address).
|
||||
addr = address if address else c.get("address", "")
|
||||
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones, "address": addr}))
|
||||
found = True
|
||||
else:
|
||||
out.append(c)
|
||||
if not found:
|
||||
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones, "address": address}))
|
||||
_save_local_contacts(out)
|
||||
return True
|
||||
|
||||
vcard = _build_vcard(name, "", uid=uid, emails=emails, phones=phones, address=address)
|
||||
# Use the real resource href (handles externally-created contacts whose
|
||||
# filename != UID); falls back to the <uid>.vcf guess.
|
||||
try:
|
||||
url = _resolve_resource_url(uid)
|
||||
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
||||
r = httpx.put(
|
||||
url,
|
||||
data=vcard.encode("utf-8"),
|
||||
headers={"Content-Type": "text/vcard; charset=utf-8"},
|
||||
auth=auth,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code in (200, 201, 204):
|
||||
_contact_cache["fetched_at"] = None
|
||||
return True
|
||||
logger.warning(f"CardDAV update PUT returned {r.status_code}: {r.text[:200]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update contact: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _delete_contact(uid: str) -> bool:
|
||||
"""Delete a contact via CardDAV or local contacts."""
|
||||
cfg = _get_carddav_config()
|
||||
if not _carddav_configured(cfg):
|
||||
contacts = _load_local_contacts()
|
||||
remaining = [c for c in contacts if c.get("uid") != uid]
|
||||
_save_local_contacts(remaining)
|
||||
return True
|
||||
|
||||
try:
|
||||
url = _resolve_resource_url(uid)
|
||||
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
||||
r = httpx.delete(url, auth=auth, timeout=10)
|
||||
if r.status_code in (200, 204, 404):
|
||||
# Invalidate cache so the next fetch sees the server truth.
|
||||
_contact_cache["fetched_at"] = None
|
||||
# Verify: force a fresh fetch and check the UID is actually gone.
|
||||
# A 404 on the guessed URL ({uid}.vcf) can mean the contact
|
||||
# lives at a different resource URL — the DELETE missed it but
|
||||
# we'd silently report success. This check catches that.
|
||||
fresh = _fetch_contacts(force=True)
|
||||
still_there = any(c.get("uid") == uid for c in fresh)
|
||||
if still_there:
|
||||
logger.warning(
|
||||
f"CardDAV DELETE reported success for {uid} "
|
||||
f"but UID still present after re-fetch — "
|
||||
f"resource URL may differ from {redact_url(url)}"
|
||||
)
|
||||
return False
|
||||
if r.status_code == 404:
|
||||
logger.info(f"CardDAV DELETE 404 for {uid} — already gone")
|
||||
return True
|
||||
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete contact: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Routes ──
|
||||
|
||||
def setup_contacts_routes():
|
||||
router = APIRouter(prefix="/api/contacts", tags=["contacts"])
|
||||
|
||||
@router.get("/list")
|
||||
async def list_contacts(_admin: str = Depends(require_admin)):
|
||||
"""List all contacts."""
|
||||
contacts = _fetch_contacts()
|
||||
return {"contacts": contacts, "count": len(contacts)}
|
||||
|
||||
@router.get("/search")
|
||||
async def search_contacts(q: str = Query(""), _admin: str = Depends(require_admin)):
|
||||
"""Search contacts by name or email. Returns up to 10 matches."""
|
||||
contacts = _fetch_contacts()
|
||||
if not q:
|
||||
return {"results": []}
|
||||
q_lower = q.lower()
|
||||
results = []
|
||||
for c in contacts:
|
||||
if q_lower in c["name"].lower():
|
||||
results.append(c)
|
||||
continue
|
||||
for em in c["emails"]:
|
||||
if q_lower in em.lower():
|
||||
results.append(c)
|
||||
break
|
||||
return {"results": results[:10]}
|
||||
|
||||
@router.post("/add")
|
||||
async def add_contact(data: dict, _admin: str = Depends(require_admin)):
|
||||
"""Add a new contact."""
|
||||
name = (data.get("name") or "").strip()
|
||||
email = (data.get("email") or "").strip()
|
||||
phone = (data.get("phone") or "").strip()
|
||||
address = (data.get("address") or "").strip()
|
||||
if not email:
|
||||
return {"success": False, "error": "Email required"}
|
||||
# Check if already exists by email
|
||||
if email:
|
||||
contacts = _fetch_contacts()
|
||||
for c in contacts:
|
||||
if email.lower() in [e.lower() for e in c["emails"]]:
|
||||
return {"success": True, "message": "Already exists", "contact": c}
|
||||
if not name:
|
||||
name = email.split("@")[0]
|
||||
create_params = inspect.signature(_create_contact).parameters
|
||||
if len(create_params) >= 3:
|
||||
ok = _create_contact(name, email, address)
|
||||
else:
|
||||
ok = _create_contact(name, email)
|
||||
# If a phone was provided, do an immediate update to thread it
|
||||
# through (the simple _create_contact signature only takes name +
|
||||
# email + address; phones happen via update).
|
||||
if ok and phone:
|
||||
try:
|
||||
fresh = _fetch_contacts(force=True)
|
||||
created = next((c for c in fresh if name == c.get("name") and (not email or email in c.get("emails", []))), None)
|
||||
if created:
|
||||
_update_contact(
|
||||
created["uid"], name,
|
||||
created.get("emails", []),
|
||||
[phone],
|
||||
address,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": ok}
|
||||
|
||||
@router.post("/import")
|
||||
async def import_vcf(data: dict, _admin: str = Depends(require_admin)):
|
||||
"""Import contacts from .vcf or CSV. Body: {"vcf": "..."} or {"csv": "..."}."""
|
||||
# Coerce defensively: a non-string vcf/text/csv (e.g. a number or list
|
||||
# in the JSON body) would otherwise reach .strip() and 500 with an
|
||||
# AttributeError instead of degrading to a clean "no data" response.
|
||||
text = str(data.get("vcf") or data.get("text") or "")
|
||||
csv_text = str(data.get("csv") or "")
|
||||
if text.strip():
|
||||
if "BEGIN:VCARD" not in text.upper():
|
||||
return {"success": False, "error": "No vCard data found"}
|
||||
result = _import_vcards(text)
|
||||
elif csv_text.strip():
|
||||
result = _import_csv_contacts(csv_text)
|
||||
else:
|
||||
return {"success": False, "error": "No contact data found"}
|
||||
result["success"] = result.get("imported", 0) > 0
|
||||
return result
|
||||
|
||||
@router.get("/export")
|
||||
async def export_contacts(
|
||||
format: str = Query("vcf", pattern="^(vcf|csv)$"),
|
||||
_admin: str = Depends(require_admin),
|
||||
):
|
||||
"""Export all contacts as vCard or CSV."""
|
||||
contacts = _fetch_contacts(force=True)
|
||||
if format == "csv":
|
||||
content = _contacts_to_csv(contacts)
|
||||
media_type = "text/csv; charset=utf-8"
|
||||
filename = "odysseus-contacts.csv"
|
||||
else:
|
||||
content = _contacts_to_vcf(contacts)
|
||||
media_type = "text/vcard; charset=utf-8"
|
||||
filename = "odysseus-contacts.vcf"
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config(_admin: str = Depends(require_admin)):
|
||||
cfg = _get_carddav_config()
|
||||
# Mask password
|
||||
if cfg["password"]:
|
||||
cfg["password"] = "***"
|
||||
return cfg
|
||||
|
||||
@router.put("/config")
|
||||
async def update_config(data: dict, _admin: str = Depends(require_admin)):
|
||||
settings = _load_settings()
|
||||
for key in ("carddav_url", "carddav_username", "carddav_password"):
|
||||
if key in data:
|
||||
if key == "carddav_url" and str(data[key] or "").strip():
|
||||
try:
|
||||
settings[key] = _validate_carddav_url(data[key])
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
else:
|
||||
value = data[key]
|
||||
if key == "carddav_password" and value:
|
||||
from src.secret_storage import encrypt
|
||||
value = encrypt(value)
|
||||
settings[key] = value
|
||||
_save_settings(settings)
|
||||
# Force re-fetch
|
||||
_contact_cache["fetched_at"] = None
|
||||
return {"success": True}
|
||||
|
||||
@router.delete("/clear")
|
||||
async def clear_contacts(_admin: str = Depends(require_admin)):
|
||||
"""Clear all local contacts. If CardDAV is configured, only clears the local fallback cache."""
|
||||
_save_local_contacts([])
|
||||
return {"success": True}
|
||||
|
||||
# NOTE: the /{uid} routes are declared LAST so the literal paths above
|
||||
# (/list, /search, /add, /config) win — otherwise PUT /config would
|
||||
# match PUT /{uid} with uid="config".
|
||||
@router.put("/{uid}")
|
||||
async def edit_contact(uid: str, data: dict, _admin: str = Depends(require_admin)):
|
||||
"""Edit an existing contact — name / emails / phones / address."""
|
||||
name = (data.get("name") or "").strip()
|
||||
emails = data.get("emails")
|
||||
phones = data.get("phones")
|
||||
if emails is None and data.get("email"):
|
||||
emails = [data["email"]]
|
||||
emails = [e.strip() for e in (emails or []) if e and e.strip()]
|
||||
phones = [p.strip() for p in (phones or []) if p and p.strip()]
|
||||
address = (data.get("address") or "").strip()
|
||||
if not name and not emails and not address:
|
||||
return {"success": False, "error": "Name, email, or address required"}
|
||||
if not name and emails:
|
||||
name = emails[0].split("@")[0]
|
||||
ok = _update_contact(uid, name, emails, phones, address)
|
||||
return {"success": ok}
|
||||
|
||||
@router.delete("/{uid}")
|
||||
async def delete_contact(uid: str, _admin: str = Depends(require_admin)):
|
||||
"""Delete a contact by UID."""
|
||||
if not uid:
|
||||
return {"success": False, "error": "UID required"}
|
||||
ok = _delete_contact(uid)
|
||||
return {"success": ok}
|
||||
|
||||
return router
|
||||
_sys.modules[__name__] = _canonical
|
||||
|
||||
+52
-11
@@ -439,15 +439,30 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
" if f.is_file(): nf += 1; sz += f.stat().st_size",
|
||||
" if f.name.endswith('.incomplete'): ic = True",
|
||||
" snap = os.path.join(cache, d, 'snapshots')",
|
||||
" # Windows HF cache stores files directly in snapshots/; blobs/ may be empty.",
|
||||
" # Fallback: scan snapshots for real files when blobs yielded nothing.",
|
||||
" if sz == 0 and os.path.isdir(snap):",
|
||||
" def snapshot_size():",
|
||||
" total, count, incomplete = 0, 0, False",
|
||||
" seen_real = set()",
|
||||
" for sd in os.listdir(snap):",
|
||||
" sf = os.path.join(snap, sd)",
|
||||
" if not os.path.isdir(sf): continue",
|
||||
" for f in os.scandir(sf):",
|
||||
" if f.is_file(): nf += 1; sz += f.stat().st_size",
|
||||
" if f.name.endswith('.incomplete'): ic = True",
|
||||
" for root, dirs, fns in safe_walk(sf):",
|
||||
" for fn in fns:",
|
||||
" fp = os.path.join(root, fn)",
|
||||
" if fn.endswith('.incomplete'): incomplete = True",
|
||||
" try:",
|
||||
" real = os.path.realpath(fp)",
|
||||
" if real in seen_real: continue",
|
||||
" seen_real.add(real)",
|
||||
" total += os.path.getsize(real)",
|
||||
" count += 1",
|
||||
" except Exception:",
|
||||
" pass",
|
||||
" return total, count, incomplete",
|
||||
" # Some HF caches (macOS/MLX/Xet-style) keep blobs elsewhere or expose",
|
||||
" # snapshot symlinks only. Size snapshots too when blob accounting is empty.",
|
||||
" if sz == 0 and os.path.isdir(snap):",
|
||||
" sz2, nf2, ic2 = snapshot_size()",
|
||||
" sz, nf, ic = sz2, nf2, ic or ic2",
|
||||
" is_diffusion = False; gguf_files = []",
|
||||
" if os.path.isdir(snap):",
|
||||
" for sd in os.listdir(snap):",
|
||||
@@ -471,7 +486,18 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
" add('/app/.cache/huggingface/hub')",
|
||||
f" add({add_hf_cache!r})" if add_hf_cache else "",
|
||||
" return candidates",
|
||||
"def normalize_model_dir(p):",
|
||||
" p = os.path.expanduser((p or '').strip())",
|
||||
" if not p: return p",
|
||||
" if os.path.isdir(p) or os.path.isabs(p): return p",
|
||||
" # Users often paste Linux absolute paths without the leading slash.",
|
||||
" # Treat home/<user>/... as /home/<user>/... so remote scans work.",
|
||||
" if p.startswith(('home/', 'mnt/', 'media/', 'data/', 'opt/', 'srv/', 'var/')):",
|
||||
" prefixed = '/' + p",
|
||||
" if os.path.isdir(prefixed): return prefixed",
|
||||
" return p",
|
||||
"def scan_dir(p):",
|
||||
" p = normalize_model_dir(p)",
|
||||
" if not os.path.isdir(p) or not safe_path(p): return",
|
||||
" for d in sorted(os.listdir(p)):",
|
||||
" if d.startswith('.'): continue",
|
||||
@@ -541,7 +567,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
"scan_ollama()",
|
||||
]
|
||||
for model_dir in model_dirs or []:
|
||||
lines.append(f"scan_dir(os.path.expanduser({model_dir!r}))")
|
||||
lines.append(f"scan_dir({model_dir!r})")
|
||||
lines.append("print(json.dumps(models))")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@@ -1273,13 +1299,15 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||
),
|
||||
(
|
||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|"
|
||||
r"(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|"
|
||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
|
||||
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
|
||||
r"Could not load any common_ops library|"
|
||||
r"Please ensure sgl_kernel is properly installed",
|
||||
"SGLang native dependencies are missing on this server.",
|
||||
"SGLang native kernel/runtime is missing or mismatched on this server.",
|
||||
[
|
||||
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
|
||||
{"label": "install OS packages: libnuma-dev python3.12-dev build-essential", "op": "manual"},
|
||||
{"label": "upgrade sglang-kernel after OS packages are installed", "op": "manual"},
|
||||
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1287,6 +1315,19 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"SGLang is not installed or not in PATH on this server.",
|
||||
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
||||
),
|
||||
(
|
||||
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
|
||||
"MLX LM is not installed on this server.",
|
||||
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||
),
|
||||
(
|
||||
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||
[
|
||||
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
|
||||
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
|
||||
],
|
||||
),
|
||||
# System build deps come BEFORE the generic llama.cpp catch-all so
|
||||
# cmake / build-essential / git missing → a specific OS-package
|
||||
# remediation instead of "install llama-cpp-python[server]" (which
|
||||
|
||||
+648
-87
File diff suppressed because it is too large
Load Diff
+71
-10
@@ -54,6 +54,18 @@ def _library_language_for_document(doc: Document) -> str:
|
||||
return doc.language or "text"
|
||||
|
||||
|
||||
def _email_source_key(content: str) -> tuple[str, str]:
|
||||
"""Return the source email identity embedded in an email draft document."""
|
||||
import re
|
||||
|
||||
text = content or ""
|
||||
uid_m = re.search(r"(?im)^X-Source-UID:\s*(.+?)\s*$", text)
|
||||
folder_m = re.search(r"(?im)^X-Source-Folder:\s*(.+?)\s*$", text)
|
||||
uid = (uid_m.group(1).strip() if uid_m else "")
|
||||
folder = (folder_m.group(1).strip() if folder_m else "INBOX")
|
||||
return uid, folder
|
||||
|
||||
|
||||
from routes.document_helpers import (
|
||||
DocumentCreate, DocumentUpdate, DocumentPatch,
|
||||
_doc_to_dict, _version_to_dict,
|
||||
@@ -99,24 +111,62 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
# the existing lenient path.
|
||||
session = _get_session_or_404(db, req.session_id, user)
|
||||
|
||||
doc_id = str(uuid.uuid4())
|
||||
ver_id = str(uuid.uuid4())
|
||||
|
||||
# If no language was supplied (e.g. cloning a doc whose language
|
||||
# was never set), detect it from the content rather than storing
|
||||
# NULL — which made the editor fall back to plain text. Defaults
|
||||
# to markdown for prose.
|
||||
language = req.language
|
||||
if not language:
|
||||
from src.agent_tools.document_tools import _looks_like_email_document, _sniff_doc_language
|
||||
from src.agent_tools.document_tools import _looks_like_email_document, _sniff_doc_language, _coerce_email_document_content
|
||||
language = _sniff_doc_language(req.content)
|
||||
else:
|
||||
from src.agent_tools.document_tools import _looks_like_email_document
|
||||
from src.agent_tools.document_tools import _looks_like_email_document, _coerce_email_document_content
|
||||
if _looks_like_email_document(req.content, req.title):
|
||||
language = "email"
|
||||
|
||||
_assert_pdf_marker_upload_owned(request, req.content, user, upload_handler)
|
||||
|
||||
# Reply drafts are keyed to the source email. If a UI/tool path tries
|
||||
# to create a second draft for the same email in the same chat,
|
||||
# update the existing draft instead so quoted thread history stays
|
||||
# attached to the visible document.
|
||||
if language == "email" and req.session_id:
|
||||
source_uid, source_folder = _email_source_key(req.content)
|
||||
if source_uid:
|
||||
candidates = (
|
||||
db.query(Document)
|
||||
.filter(Document.session_id == req.session_id)
|
||||
.filter(Document.is_active == True)
|
||||
.filter(Document.language == "email")
|
||||
.order_by(Document.updated_at.desc())
|
||||
.limit(25)
|
||||
.all()
|
||||
)
|
||||
for existing in candidates:
|
||||
old_uid, old_folder = _email_source_key(existing.current_content or "")
|
||||
if old_uid != source_uid or old_folder != source_folder:
|
||||
continue
|
||||
merged = _coerce_email_document_content(existing.current_content or "", req.content)
|
||||
if existing.current_content != merged:
|
||||
new_ver = (existing.version_count or 1) + 1
|
||||
existing.current_content = merged
|
||||
existing.title = req.title or existing.title
|
||||
existing.version_count = new_ver
|
||||
db.add(DocumentVersion(
|
||||
id=str(uuid.uuid4()),
|
||||
document_id=existing.id,
|
||||
version_number=new_ver,
|
||||
content=merged,
|
||||
summary="Updated existing email draft",
|
||||
source="user",
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return _doc_to_dict(existing)
|
||||
|
||||
doc_id = str(uuid.uuid4())
|
||||
ver_id = str(uuid.uuid4())
|
||||
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
session_id=req.session_id,
|
||||
@@ -570,12 +620,23 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
raise HTTPException(404, "Document not found")
|
||||
_verify_doc_owner(db, doc, user)
|
||||
|
||||
incoming_content = req.content
|
||||
from src.agent_tools.document_tools import _coerce_email_document_content, _looks_like_email_document
|
||||
is_email_doc = (
|
||||
(doc.language or "").lower() == "email"
|
||||
or _looks_like_email_document(doc.current_content or "", doc.title or "")
|
||||
or _looks_like_email_document(req.content or "", doc.title or "")
|
||||
)
|
||||
if is_email_doc:
|
||||
incoming_content = _coerce_email_document_content(doc.current_content or "", req.content)
|
||||
doc.language = "email"
|
||||
|
||||
# Skip if content is identical unless the caller explicitly wants
|
||||
# a checkpoint version from the current editor state.
|
||||
if doc.current_content == req.content and not req.force_version:
|
||||
if doc.current_content == incoming_content and not req.force_version:
|
||||
return _doc_to_dict(doc)
|
||||
|
||||
_assert_pdf_marker_upload_owned(request, req.content, user, upload_handler)
|
||||
_assert_pdf_marker_upload_owned(request, incoming_content, user, upload_handler)
|
||||
|
||||
# Check if we can coalesce with the latest version
|
||||
latest_ver = db.query(DocumentVersion).filter(
|
||||
@@ -591,7 +652,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
age = (now - ver_time).total_seconds()
|
||||
if age < VERSION_COALESCE_SECONDS:
|
||||
# Update the existing version in-place
|
||||
latest_ver.content = req.content
|
||||
latest_ver.content = incoming_content
|
||||
latest_ver.created_at = now
|
||||
if req.summary:
|
||||
latest_ver.summary = req.summary
|
||||
@@ -603,14 +664,14 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
id=str(uuid.uuid4()),
|
||||
document_id=doc_id,
|
||||
version_number=new_ver,
|
||||
content=req.content,
|
||||
content=incoming_content,
|
||||
summary=req.summary or "Manual edit",
|
||||
source="user",
|
||||
)
|
||||
doc.version_count = new_ver
|
||||
db.add(ver)
|
||||
|
||||
doc.current_content = req.content
|
||||
doc.current_content = incoming_content
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
return _doc_to_dict(doc)
|
||||
|
||||
+25
-4
@@ -349,7 +349,7 @@ def _assert_owns_account(account_id: str, owner: str) -> None:
|
||||
row = db.query(_EA).filter(_EA.id == account_id).first()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Account not found")
|
||||
if row.owner and row.owner != owner:
|
||||
if not _account_visible_to_owner(row, owner):
|
||||
# Treat as 404 (not 403) so we don't leak existence.
|
||||
raise HTTPException(404, "Account not found")
|
||||
finally:
|
||||
@@ -362,6 +362,26 @@ def _assert_owns_account(account_id: str, owner: str) -> None:
|
||||
logger.error(f"Account-owner check failed: {e}")
|
||||
raise HTTPException(503, "Account check failed")
|
||||
|
||||
|
||||
def _account_visible_to_owner(row, owner: str) -> bool:
|
||||
"""Whether an authenticated `owner` may act on this EmailAccount row.
|
||||
|
||||
Mirrors the SQL predicate in `_get_email_config`'s
|
||||
`_owner_or_matching_legacy_account`: a caller sees an account they own, or a
|
||||
legacy owner-less account (owner NULL/"") only when its own mailbox
|
||||
(`imap_user` / `from_address`) is the caller's. `email_accounts` is the one
|
||||
owner-scoped table deliberately left out of the legacy-owner migration
|
||||
backfill, so ownerless rows persist on multi-user deploys — making this the
|
||||
gate that keeps one tenant off another's imported mailbox and its decrypted
|
||||
IMAP/SMTP credentials."""
|
||||
row_owner = getattr(row, "owner", None) or ""
|
||||
if row_owner:
|
||||
return row_owner == owner
|
||||
return owner in {
|
||||
getattr(row, "imap_user", None) or "",
|
||||
getattr(row, "from_address", None) or "",
|
||||
}
|
||||
|
||||
def _q(name: str) -> str:
|
||||
"""Quote an IMAP mailbox name. Defensive: escapes `\\` and `"` and wraps
|
||||
in double quotes so user-supplied folder names with spaces or quotes can't
|
||||
@@ -903,12 +923,13 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
|
||||
try:
|
||||
if account_id:
|
||||
row = db.query(_EA).filter(_EA.id == account_id, _EA.enabled == True).first() # noqa: E712
|
||||
# If the resolved row belongs to a different owner, treat as
|
||||
# If the resolved row isn't visible to this owner, treat as
|
||||
# not-found rather than silently serving it. This is a defense
|
||||
# in depth — `require_owner` already calls `_assert_owns_account`
|
||||
# for query-param account_ids, but other callers (cookbook
|
||||
# rules, scheduled poller) may not.
|
||||
if row is not None and owner and row.owner and row.owner != owner:
|
||||
# rules, scheduled poller) may not. Ownerless legacy rows are
|
||||
# only visible on a mailbox match, same as the fallback below.
|
||||
if row is not None and owner and not _account_visible_to_owner(row, owner):
|
||||
row = None
|
||||
# Fallback path — restrict to this owner's accounts so we don't
|
||||
# leak another user's default mailbox to an unconfigured user.
|
||||
|
||||
+308
-28
@@ -23,6 +23,8 @@ import smtplib
|
||||
import json
|
||||
import re
|
||||
import html
|
||||
import io
|
||||
import zipfile
|
||||
from html.parser import HTMLParser as _HTMLParser
|
||||
import logging
|
||||
import uuid
|
||||
@@ -33,7 +35,7 @@ from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
from src.llm_core import llm_call_async
|
||||
@@ -65,6 +67,14 @@ ODYSSEUS_MAIL_ORIGIN = "odysseus-ui"
|
||||
EMAIL_READ_ATTACHMENT_VERSION = 2
|
||||
|
||||
|
||||
def _safe_attachment_zip_name(name: str, fallback: str) -> str:
|
||||
"""Return a zip entry filename without path traversal or empty names."""
|
||||
base = Path(str(name or "")).name.strip() or fallback
|
||||
base = re.sub(r"[\x00-\x1f\x7f]+", "_", base)
|
||||
base = base.replace("/", "_").replace("\\", "_").strip(". ") or fallback
|
||||
return base[:180] or fallback
|
||||
|
||||
|
||||
def _coerce_port(value, default):
|
||||
"""Coerce a user-supplied port to int.
|
||||
|
||||
@@ -484,23 +494,37 @@ def _email_index_rows(owner: str, account_id: str | None, folder: str, uids: lis
|
||||
return out
|
||||
|
||||
|
||||
def _email_index_search(owner: str, account_id: str | None, folder: str, query: str, limit: int) -> tuple[list[dict], int, str | None]:
|
||||
def _email_index_search(owner: str, account_id: str | None, folder: str, query: str, limit: int, global_search: bool = True) -> tuple[list[dict], int, str | None]:
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return [], 0, None
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
account_key = _account_cache_key(account_id, owner)
|
||||
like = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||
folder_clause = ""
|
||||
params: list = [owner or "", account_key]
|
||||
# Searching from INBOX should feel global for Gmail-style accounts,
|
||||
# because users expect archived/labelled mail to show up too. The
|
||||
# local index only contains folders that have been warmed/listed, so
|
||||
# this remains a best-effort fast path; IMAP is still the fallback.
|
||||
if (folder or "").upper() != "INBOX":
|
||||
if not global_search or (folder or "").upper() != "INBOX":
|
||||
folder_clause = "AND folder=?"
|
||||
params.append(folder)
|
||||
params.extend([like, like, like, like, like])
|
||||
terms = _email_search_terms(q)
|
||||
if not terms:
|
||||
return [], 0, None
|
||||
term_clause = " AND ".join([
|
||||
"""(
|
||||
subject LIKE ? ESCAPE '\\' OR
|
||||
from_name LIKE ? ESCAPE '\\' OR
|
||||
from_address LIKE ? ESCAPE '\\' OR
|
||||
to_text LIKE ? ESCAPE '\\' OR
|
||||
cc_text LIKE ? ESCAPE '\\'
|
||||
)"""
|
||||
for _ in terms
|
||||
])
|
||||
for term in terms:
|
||||
like = "%" + term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||
params.extend([like, like, like, like, like])
|
||||
try:
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
@@ -509,13 +533,7 @@ def _email_index_search(owner: str, account_id: str | None, folder: str, query:
|
||||
SELECT COUNT(*), MAX(updated_at)
|
||||
FROM email_message_index
|
||||
WHERE owner=? AND account_key=? {folder_clause}
|
||||
AND (
|
||||
subject LIKE ? ESCAPE '\\' OR
|
||||
from_name LIKE ? ESCAPE '\\' OR
|
||||
from_address LIKE ? ESCAPE '\\' OR
|
||||
to_text LIKE ? ESCAPE '\\' OR
|
||||
cc_text LIKE ? ESCAPE '\\'
|
||||
)
|
||||
AND {term_clause}
|
||||
""",
|
||||
params,
|
||||
).fetchone()
|
||||
@@ -529,13 +547,7 @@ def _email_index_search(owner: str, account_id: str | None, folder: str, query:
|
||||
folder
|
||||
FROM email_message_index
|
||||
WHERE owner=? AND account_key=? {folder_clause}
|
||||
AND (
|
||||
subject LIKE ? ESCAPE '\\' OR
|
||||
from_name LIKE ? ESCAPE '\\' OR
|
||||
from_address LIKE ? ESCAPE '\\' OR
|
||||
to_text LIKE ? ESCAPE '\\' OR
|
||||
cc_text LIKE ? ESCAPE '\\'
|
||||
)
|
||||
AND {term_clause}
|
||||
ORDER BY date_epoch DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
@@ -573,6 +585,64 @@ def _email_index_search(owner: str, account_id: str | None, folder: str, query:
|
||||
return emails, total, (total_row or [None, None])[1]
|
||||
|
||||
|
||||
def _email_search_terms(query: str) -> list[str]:
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
# Preserve quoted phrases, then split the rest. This makes:
|
||||
# honda insurance -> honda AND insurance
|
||||
# "Yoko Honda" insurance -> "Yoko Honda" AND insurance
|
||||
# The cap avoids creating huge IMAP expressions from pasted paragraphs.
|
||||
parts = []
|
||||
consumed = []
|
||||
for m in re.finditer(r'"([^"]{1,120})"', q):
|
||||
phrase = m.group(1).strip()
|
||||
if phrase:
|
||||
parts.append(phrase)
|
||||
consumed.append((m.start(), m.end()))
|
||||
remainder = q
|
||||
for start, end in reversed(consumed):
|
||||
remainder = remainder[:start] + " " + remainder[end:]
|
||||
parts.extend(re.findall(r"[^\s,;]+", remainder))
|
||||
out = []
|
||||
seen = set()
|
||||
for p in parts:
|
||||
p = p.strip().strip('"').strip()
|
||||
if len(p) < 2:
|
||||
continue
|
||||
key = p.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(p)
|
||||
if len(out) >= 6:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _imap_or_many(keys: list[str]) -> str:
|
||||
if not keys:
|
||||
return "ALL"
|
||||
expr = keys[0]
|
||||
for key in keys[1:]:
|
||||
expr = f"OR ({expr}) ({key})"
|
||||
return expr
|
||||
|
||||
|
||||
def _email_imap_search_criteria(query: str) -> str:
|
||||
terms = _email_search_terms(query)
|
||||
if not terms:
|
||||
return "ALL"
|
||||
term_exprs = []
|
||||
for term in terms:
|
||||
q = _imap_search_quote(term)
|
||||
# Search both sides of the conversation, plus subject and body. The
|
||||
# older route only searched FROM/SUBJECT/TEXT, so recipient searches
|
||||
# and many sent-message searches felt broken.
|
||||
term_exprs.append(f"({_imap_or_many([f'FROM {q}', f'TO {q}', f'CC {q}', f'SUBJECT {q}', f'TEXT {q}'])})")
|
||||
return "(" + " ".join(term_exprs) + ")"
|
||||
|
||||
|
||||
def _email_index_upsert(owner: str, account_id: str | None, folder: str, emails: list[dict]):
|
||||
if not emails:
|
||||
return
|
||||
@@ -2049,6 +2119,7 @@ def setup_email_routes():
|
||||
limit: int = Query(50),
|
||||
account_id: str | None = Query(None),
|
||||
local_only: bool = Query(False),
|
||||
scope: str = Query("all"),
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
"""Search emails server-side via IMAP SEARCH. Matches subject, from, or body text.
|
||||
@@ -2062,9 +2133,10 @@ def setup_email_routes():
|
||||
# CRLF in q would terminate the IMAP command early — reject defensively.
|
||||
if "\r" in q or "\n" in q:
|
||||
raise HTTPException(400, "Invalid query")
|
||||
global_search = (scope or "all").lower() != "folder"
|
||||
indexed_response = None
|
||||
try:
|
||||
indexed_emails, indexed_total, indexed_at = _email_index_search(owner, account_id, folder, q, limit)
|
||||
indexed_emails, indexed_total, indexed_at = _email_index_search(owner, account_id, folder, q, limit, global_search=global_search)
|
||||
indexed_response = {
|
||||
"emails": indexed_emails,
|
||||
"total": indexed_total,
|
||||
@@ -2083,7 +2155,7 @@ def setup_email_routes():
|
||||
# If the user asked for INBOX, try to upgrade to All Mail —
|
||||
# one folder == every email on Gmail-class servers.
|
||||
effective_folder = folder
|
||||
if (folder or "").upper() == "INBOX":
|
||||
if global_search and (folder or "").upper() == "INBOX":
|
||||
try:
|
||||
status, folder_lines = conn.list()
|
||||
if status == "OK" and folder_lines:
|
||||
@@ -2102,12 +2174,18 @@ def setup_email_routes():
|
||||
pass
|
||||
conn.select(_q(effective_folder), readonly=True)
|
||||
|
||||
# Escape backslash and quote for the IMAP-SEARCH quoted-string.
|
||||
q_escaped = q.replace('\\', '\\\\').replace('"', '\\"')
|
||||
search_cmd = f'(OR OR FROM "{q_escaped}" SUBJECT "{q_escaped}" TEXT "{q_escaped}")'
|
||||
search_cmd = _email_imap_search_criteria(q)
|
||||
|
||||
status, data = _imap_uid_search(conn, search_cmd)
|
||||
if status != "OK" or not data[0]:
|
||||
if indexed_response and indexed_response.get("emails"):
|
||||
indexed_response["fallback"] = True
|
||||
indexed_response["source"] = "index"
|
||||
indexed_response["sync"] = {
|
||||
**(indexed_response.get("sync") or {}),
|
||||
"fallback_reason": "imap_empty" if status == "OK" else "imap_search_failed",
|
||||
}
|
||||
return indexed_response
|
||||
return {
|
||||
"emails": [],
|
||||
"total": 0,
|
||||
@@ -2530,6 +2608,59 @@ def setup_email_routes():
|
||||
logger.error(f"Failed to download attachment {uid}/{index}: {e}")
|
||||
return {"error": "Mail operation failed"}
|
||||
|
||||
@router.get("/attachments-download/{uid}")
|
||||
async def download_all_attachments(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
|
||||
"""Download all visible attachments for an email as a zip archive."""
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
conn.select(_q(folder), readonly=True)
|
||||
status, msg_data = _imap_uid_fetch(conn, uid, "(RFC822)")
|
||||
if status != "OK":
|
||||
raise HTTPException(status_code=404, detail="Email not found")
|
||||
raw = msg_data[0][1]
|
||||
msg = email_mod.message_from_bytes(raw)
|
||||
attachments = [
|
||||
att for att in _list_attachments_from_msg(msg)
|
||||
if not _is_likely_signature_image_attachment(att)
|
||||
]
|
||||
if not attachments:
|
||||
raise HTTPException(status_code=404, detail="No downloadable attachments")
|
||||
|
||||
target_dir = attachment_extract_dir(folder, uid)
|
||||
zip_buf = io.BytesIO()
|
||||
used_names: dict[str, int] = {}
|
||||
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for att in attachments:
|
||||
idx = att.get("index")
|
||||
if idx is None:
|
||||
continue
|
||||
filepath = _extract_attachment_to_disk(msg, int(idx), target_dir)
|
||||
if not filepath or not Path(filepath).exists():
|
||||
continue
|
||||
fallback = f"attachment-{idx}"
|
||||
arcname = _safe_attachment_zip_name(att.get("filename") or Path(filepath).name, fallback)
|
||||
stem = Path(arcname).stem
|
||||
suffix = Path(arcname).suffix
|
||||
seen = used_names.get(arcname, 0)
|
||||
used_names[arcname] = seen + 1
|
||||
if seen:
|
||||
arcname = f"{stem}-{seen + 1}{suffix}"
|
||||
zf.write(str(filepath), arcname)
|
||||
zip_buf.seek(0)
|
||||
if not zip_buf.getbuffer().nbytes:
|
||||
raise HTTPException(status_code=404, detail="No downloadable attachments")
|
||||
zip_name = _safe_attachment_zip_name(f"email-{uid}-attachments.zip", "attachments.zip")
|
||||
return StreamingResponse(
|
||||
zip_buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download attachments zip {uid}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Mail operation failed")
|
||||
|
||||
@router.get("/inline-image/{uid}")
|
||||
async def inline_image(
|
||||
uid: str,
|
||||
@@ -3150,6 +3281,155 @@ def setup_email_routes():
|
||||
logger.error(f"Failed to upload attachment: {e}")
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
def _safe_compose_filename(name: str, fallback: str = "attachment") -> str:
|
||||
safe_name = re.sub(r"[^\w\s\-.]", "_", Path(str(name or fallback)).name).strip(". ")[:180]
|
||||
return safe_name or fallback
|
||||
|
||||
def _stage_compose_bytes(filename: str, content: bytes) -> dict:
|
||||
if len(content) > EMAIL_COMPOSE_UPLOAD_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Attachment too large")
|
||||
safe_name = _safe_compose_filename(filename)
|
||||
token = f"{uuid.uuid4().hex}_{safe_name}"
|
||||
filepath = COMPOSE_UPLOADS_DIR / token
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(content)
|
||||
return {"success": True, "token": token, "filename": safe_name, "size": len(content)}
|
||||
|
||||
def _stage_compose_file(filename: str, src: Path) -> dict:
|
||||
if not src.exists() or not src.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
size = src.stat().st_size
|
||||
if size > EMAIL_COMPOSE_UPLOAD_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Attachment too large")
|
||||
safe_name = _safe_compose_filename(filename)
|
||||
token = f"{uuid.uuid4().hex}_{safe_name}"
|
||||
dest = COMPOSE_UPLOADS_DIR / token
|
||||
import shutil as _shutil
|
||||
_shutil.copyfile(str(src), str(dest))
|
||||
return {"success": True, "token": token, "filename": safe_name, "size": size}
|
||||
|
||||
def _load_odysseus_attachment_source(db, kind: str, item_id: str, owner: str):
|
||||
from core.database import Document as _Doc, GalleryImage as _GI
|
||||
from core.database import Session as _Sess
|
||||
|
||||
if kind == "document":
|
||||
doc = db.query(_Doc).filter(_Doc.id == item_id, _Doc.is_active == True).first()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
if owner:
|
||||
if doc.owner and doc.owner != owner:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
if not doc.owner and doc.session_id:
|
||||
sess = db.query(_Sess).filter(_Sess.id == doc.session_id).first()
|
||||
if sess and sess.owner and sess.owner != owner:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
lang = (doc.language or "text").strip().lower()
|
||||
ext = {
|
||||
"markdown": "md",
|
||||
"email": "eml",
|
||||
"json": "json",
|
||||
"yaml": "yaml",
|
||||
"yml": "yml",
|
||||
"html": "html",
|
||||
"csv": "csv",
|
||||
"xml": "xml",
|
||||
"text": "txt",
|
||||
}.get(lang, "txt")
|
||||
base = _safe_compose_filename(doc.title or "document", "document")
|
||||
if not base.lower().endswith(f".{ext}"):
|
||||
base = f"{base}.{ext}"
|
||||
return {"filename": base, "content": (doc.current_content or "").encode("utf-8")}
|
||||
|
||||
if kind == "gallery":
|
||||
img = db.query(_GI).filter(_GI.id == item_id, _GI.is_active == True).first()
|
||||
if not img:
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
if owner and img.owner and img.owner != owner:
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
from routes.gallery.gallery_routes import _gallery_image_path
|
||||
src = _gallery_image_path(img.filename)
|
||||
if not src.exists() or not src.is_file():
|
||||
raise HTTPException(status_code=404, detail="Image file not found")
|
||||
return {"filename": _safe_compose_filename(img.filename or "gallery-image.png"), "path": src}
|
||||
|
||||
raise HTTPException(status_code=400, detail="Unknown attachment kind")
|
||||
|
||||
@router.post("/compose-from-odysseus")
|
||||
async def compose_from_odysseus(data: dict, owner: str = Depends(require_owner)):
|
||||
"""Stage an Odysseus document or gallery image as a compose upload."""
|
||||
kind = str(data.get("kind") or "").strip().lower()
|
||||
item_id = str(data.get("id") or "").strip()
|
||||
if kind not in {"document", "gallery"} or not item_id:
|
||||
raise HTTPException(status_code=400, detail="Expected kind and id")
|
||||
try:
|
||||
from core.database import SessionLocal as _SL
|
||||
|
||||
db = _SL()
|
||||
try:
|
||||
src = _load_odysseus_attachment_source(db, kind, item_id, owner)
|
||||
if "path" in src:
|
||||
return _stage_compose_file(src["filename"], src["path"])
|
||||
return _stage_compose_bytes(src["filename"], src["content"])
|
||||
finally:
|
||||
db.close()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stage Odysseus attachment {kind}/{item_id}: {e}")
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
@router.post("/compose-from-odysseus-zip")
|
||||
async def compose_from_odysseus_zip(data: dict, owner: str = Depends(require_owner)):
|
||||
"""Stage several Odysseus documents/gallery images as one zip attachment."""
|
||||
raw_items = data.get("items") or []
|
||||
if not isinstance(raw_items, list) or not raw_items:
|
||||
raise HTTPException(status_code=400, detail="Expected items")
|
||||
if len(raw_items) > 100:
|
||||
raise HTTPException(status_code=400, detail="Too many attachments")
|
||||
try:
|
||||
from core.database import SessionLocal as _SL
|
||||
|
||||
db = _SL()
|
||||
try:
|
||||
buf = io.BytesIO()
|
||||
used_names: dict[str, int] = {}
|
||||
|
||||
def unique_name(name: str) -> str:
|
||||
safe = _safe_attachment_zip_name(name, "attachment")
|
||||
stem = Path(safe).stem or "attachment"
|
||||
suffix = Path(safe).suffix
|
||||
idx = used_names.get(safe, 0)
|
||||
used_names[safe] = idx + 1
|
||||
if idx == 0:
|
||||
return safe
|
||||
return f"{stem}-{idx + 1}{suffix}"
|
||||
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for item in raw_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
kind = str(item.get("kind") or "").strip().lower()
|
||||
item_id = str(item.get("id") or "").strip()
|
||||
if kind not in {"document", "gallery"} or not item_id:
|
||||
continue
|
||||
src = _load_odysseus_attachment_source(db, kind, item_id, owner)
|
||||
zname = unique_name(src["filename"])
|
||||
if "path" in src:
|
||||
zf.write(src["path"], arcname=zname)
|
||||
else:
|
||||
zf.writestr(zname, src["content"])
|
||||
content = buf.getvalue()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="No valid attachments")
|
||||
return _stage_compose_bytes("odysseus-attachments.zip", content)
|
||||
finally:
|
||||
db.close()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stage Odysseus zip attachment: {e}")
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
@router.post("/compose-from-attachment/{uid}/{index}")
|
||||
async def compose_from_attachment(
|
||||
uid: str,
|
||||
@@ -4423,7 +4703,9 @@ def setup_email_routes():
|
||||
cfg["email_auto_tag"] = bool(settings.get("email_auto_tag", False))
|
||||
cfg["email_auto_spam"] = bool(settings.get("email_auto_spam", False))
|
||||
cfg["email_auto_calendar"] = bool(settings.get("email_auto_calendar", False))
|
||||
cfg["email_auto_translate"] = bool(settings.get("email_auto_translate", False))
|
||||
# Email translation is owned by the background task now; opening an email
|
||||
# should not trigger reader-side auto-translation from Settings.
|
||||
cfg["email_auto_translate"] = False
|
||||
cfg["email_translate_language"] = settings.get("email_translate_language", "English")
|
||||
return cfg
|
||||
|
||||
@@ -4438,11 +4720,9 @@ def setup_email_routes():
|
||||
"""
|
||||
# Automation flags stay in settings.json (they're global, not per-account)
|
||||
settings = _load_settings()
|
||||
for key in ["email_auto_summarize", "email_auto_reply", "email_auto_tag", "email_auto_spam", "email_auto_calendar", "email_auto_translate"]:
|
||||
for key in ["email_auto_summarize", "email_auto_reply", "email_auto_tag", "email_auto_spam", "email_auto_calendar"]:
|
||||
if key in data:
|
||||
settings[key] = data[key]
|
||||
if "email_translate_language" in data:
|
||||
settings["email_translate_language"] = (data.get("email_translate_language") or "English").strip() or "English"
|
||||
_save_settings(settings)
|
||||
|
||||
# Credentials go into the default account row
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""History route domain package (slice 2d, #4082/#4071).
|
||||
|
||||
Contains history_routes.py, migrated from the flat routes/ directory.
|
||||
Backward-compat shim at routes/history_routes.py re-exports from here.
|
||||
"""
|
||||
@@ -0,0 +1,768 @@
|
||||
"""History routes — session history, truncation, fork, conversation topics."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
|
||||
from core.models import ChatMessage
|
||||
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
||||
from src.topic_analyzer import analyze_topics
|
||||
from routes.session_routes import (
|
||||
_message_role,
|
||||
_message_text,
|
||||
_reject_compact_during_active_run,
|
||||
_verify_session_owner,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HISTORY_INLINE_MEDIA_THRESHOLD = 200_000
|
||||
_DATA_IMAGE_RE = re.compile(r"data:image/[^;,\"]+;base64,[A-Za-z0-9+/=\s]+")
|
||||
|
||||
|
||||
def _history_display_content(content: Any) -> Any:
|
||||
"""Return a lightweight browser-display copy of stored message content.
|
||||
|
||||
Older multimodal user messages may be persisted as a JSON *string*
|
||||
containing image_url blocks with inline base64 image bytes. Those bytes are
|
||||
needed for model calls when the turn is first sent, but they should not be
|
||||
sent back through /api/history every time the user opens the chat. The
|
||||
attachment metadata already carries file ids/names for the UI cards.
|
||||
"""
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
omitted_media = 0
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") == "text":
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
text_parts.append(text)
|
||||
elif block.get("type") in {"image_url", "input_image", "audio", "input_audio"}:
|
||||
omitted_media += 1
|
||||
text = "\n".join(text_parts).strip()
|
||||
if omitted_media and not text:
|
||||
return f"[{omitted_media} media attachment{'s' if omitted_media != 1 else ''} omitted from history view]"
|
||||
return text
|
||||
|
||||
if not isinstance(content, str):
|
||||
return content
|
||||
if len(content) < _HISTORY_INLINE_MEDIA_THRESHOLD and "data:image/" not in content:
|
||||
return content
|
||||
|
||||
stripped = content.lstrip()
|
||||
if stripped.startswith("["):
|
||||
try:
|
||||
blocks = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
blocks = None
|
||||
if isinstance(blocks, list):
|
||||
text_parts = []
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") == "text":
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
text_parts.append(text)
|
||||
if text_parts:
|
||||
return "\n".join(text_parts).strip()
|
||||
|
||||
if "data:image/" in content:
|
||||
return _DATA_IMAGE_RE.sub("[inline image omitted from history view]", content)
|
||||
return content
|
||||
|
||||
|
||||
def _merge_continue_rows_to_delete(db_messages, db1, db2):
|
||||
"""DB rows to delete when merging the last two assistant messages.
|
||||
|
||||
Always the second assistant message (db2), plus ONLY the single
|
||||
intervening "continue" user message (the one carrying "previous response
|
||||
was interrupted") — matching the in-memory merge. The previous code
|
||||
deleted the whole index range between the two assistant rows, destroying
|
||||
any tool/system/user messages in between and desyncing the DB from the
|
||||
in-memory history.
|
||||
"""
|
||||
to_delete = [db2]
|
||||
i1 = next((i for i, m in enumerate(db_messages) if m is db1), None)
|
||||
i2 = next((i for i, m in enumerate(db_messages) if m is db2), None)
|
||||
if i1 is not None and i2 is not None and i2 - 1 > i1:
|
||||
between = db_messages[i2 - 1]
|
||||
if getattr(between, "role", "") == "user" and "previous response was interrupted" in (getattr(between, "content", "") or ""):
|
||||
to_delete.append(between)
|
||||
return to_delete
|
||||
|
||||
|
||||
def setup_history_routes(session_manager) -> APIRouter:
|
||||
router = APIRouter(tags=["history"])
|
||||
|
||||
def _db_history_entry(m: DbChatMessage) -> Dict[str, Any]:
|
||||
entry = {"role": m.role, "content": _history_display_content(m.content)}
|
||||
meta = {}
|
||||
if m.meta_data:
|
||||
try:
|
||||
meta = json.loads(m.meta_data) or {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
meta = {}
|
||||
if m.timestamp and "timestamp" not in meta:
|
||||
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
||||
if meta:
|
||||
entry["metadata"] = meta
|
||||
return entry
|
||||
|
||||
@router.get("/api/history/{session_id}")
|
||||
async def get_session_history(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
_verify_session_owner(request, session_id)
|
||||
if limit is not None:
|
||||
page_limit = max(1, min(int(limit), 100))
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session is None:
|
||||
raise HTTPException(404, f"Session '{session_id}' not found")
|
||||
|
||||
total = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.count()
|
||||
)
|
||||
page_offset = int(offset) if offset is not None else max(total - page_limit, 0)
|
||||
page_offset = max(0, min(page_offset, total))
|
||||
rows = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.order_by(DbChatMessage.timestamp)
|
||||
.offset(page_offset)
|
||||
.limit(page_limit)
|
||||
.all()
|
||||
)
|
||||
history_dict = [
|
||||
entry for entry in (_db_history_entry(m) for m in rows)
|
||||
if not (entry.get("metadata") or {}).get("hidden")
|
||||
]
|
||||
return {
|
||||
"history": history_dict,
|
||||
"model": db_session.model,
|
||||
"endpoint_url": db_session.endpoint_url,
|
||||
"name": db_session.name,
|
||||
"offset": page_offset,
|
||||
"limit": page_limit,
|
||||
"total": total,
|
||||
"has_more_before": page_offset > 0,
|
||||
"has_more_after": page_offset + len(rows) < total,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, f"Session '{session_id}' not found")
|
||||
|
||||
history_dict = []
|
||||
for msg in session.history:
|
||||
if isinstance(msg, ChatMessage):
|
||||
# Skip hidden messages (e.g. compaction summaries for AI context)
|
||||
if msg.metadata and msg.metadata.get("hidden"):
|
||||
continue
|
||||
entry = {"role": msg.role, "content": _history_display_content(msg.content)}
|
||||
if msg.metadata:
|
||||
entry["metadata"] = msg.metadata
|
||||
history_dict.append(entry)
|
||||
elif isinstance(msg, dict):
|
||||
if msg.get("metadata", {}).get("hidden"):
|
||||
continue
|
||||
entry = {
|
||||
"role": msg.get("role", ""),
|
||||
"content": _history_display_content(msg.get("content", "")),
|
||||
}
|
||||
if msg.get("metadata"):
|
||||
entry["metadata"] = msg["metadata"]
|
||||
history_dict.append(entry)
|
||||
|
||||
# Fallback: load from DB if in-memory is empty
|
||||
if not history_dict:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_messages = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.order_by(DbChatMessage.timestamp)
|
||||
.all()
|
||||
)
|
||||
db_history = []
|
||||
for m in db_messages:
|
||||
db_history.append(_db_history_entry(m))
|
||||
if db_history:
|
||||
# Rebuild in-memory history from the full set so hidden
|
||||
# messages (e.g. compaction summaries) are kept for AI context.
|
||||
session.history = [
|
||||
ChatMessage(role=m["role"], content=m["content"], metadata=m.get("metadata"))
|
||||
for m in db_history
|
||||
]
|
||||
# Response excludes hidden messages, matching the in-memory path.
|
||||
history_dict = [
|
||||
m for m in db_history
|
||||
if not (m.get("metadata") or {}).get("hidden")
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"DB fallback failed for {session_id}: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {
|
||||
"history": history_dict,
|
||||
"model": session.model,
|
||||
"endpoint_url": session.endpoint_url,
|
||||
"name": session.name,
|
||||
}
|
||||
|
||||
@router.post("/api/session/{session_id}/truncate")
|
||||
async def truncate_session(request: Request, session_id: str):
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
keep_count = body.get("keep_count", 0)
|
||||
result = session_manager.truncate_messages(session_id, keep_count)
|
||||
return {"status": "ok", "kept": keep_count, "truncated": result}
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Truncate error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/message")
|
||||
async def add_message(request: Request, session_id: str):
|
||||
"""Add a message to a session (for slash command persistence)."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
role = body.get("role", "assistant")
|
||||
content = body.get("content", "")
|
||||
if not content:
|
||||
raise HTTPException(400, "content is required")
|
||||
msg = ChatMessage(role=role, content=content, metadata=body.get("metadata"))
|
||||
session_manager.add_message(session_id, msg)
|
||||
return {"status": "ok"}
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
@router.post("/api/session/{session_id}/delete-messages")
|
||||
async def delete_messages(request: Request, session_id: str):
|
||||
"""Delete specific messages by DB ID (or legacy index)."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
msg_ids = body.get("msg_ids", [])
|
||||
indices = body.get("indices") # legacy fallback
|
||||
|
||||
session = session_manager.get_session(session_id)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if msg_ids:
|
||||
# New ID-based delete
|
||||
deleted = 0
|
||||
for mid in msg_ids:
|
||||
db_msg = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.id == mid,
|
||||
DbChatMessage.session_id == session_id,
|
||||
).first()
|
||||
if db_msg:
|
||||
db.delete(db_msg)
|
||||
deleted += 1
|
||||
|
||||
# Remove from in-memory history by matching _db_id
|
||||
def _get_db_id(m):
|
||||
meta = m.metadata if isinstance(m, ChatMessage) else (m.get('metadata') if isinstance(m, dict) else None)
|
||||
return meta.get('_db_id') if isinstance(meta, dict) else None
|
||||
session.history = [m for m in session.history if _get_db_id(m) not in msg_ids]
|
||||
elif indices:
|
||||
# Legacy index-based delete
|
||||
indices = sorted(indices, reverse=True)
|
||||
db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
|
||||
deleted = 0
|
||||
for idx in indices:
|
||||
if 0 <= idx < len(db_messages):
|
||||
db.delete(db_messages[idx])
|
||||
deleted += 1
|
||||
if 0 <= idx < len(session.history):
|
||||
session.history.pop(idx)
|
||||
else:
|
||||
return {"status": "ok", "deleted": 0}
|
||||
|
||||
session.message_count = len(session.history)
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.message_count = len(session.history)
|
||||
from datetime import datetime, timezone
|
||||
db_session.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
return {"status": "ok", "deleted": deleted}
|
||||
finally:
|
||||
db.close()
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Delete messages error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/edit-message")
|
||||
async def edit_message(request: Request, session_id: str):
|
||||
"""Edit the content of a message by its database ID."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
msg_id = body.get("msg_id")
|
||||
content = body.get("content")
|
||||
if not msg_id or content is None:
|
||||
raise HTTPException(400, "msg_id and content are required")
|
||||
|
||||
session = session_manager.get_session(session_id)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_msg = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.id == msg_id,
|
||||
DbChatMessage.session_id == session_id,
|
||||
).first()
|
||||
if not db_msg:
|
||||
raise HTTPException(404, "Message not found")
|
||||
|
||||
db_msg.content = content
|
||||
meta = {}
|
||||
if db_msg.meta_data:
|
||||
try: meta = json.loads(db_msg.meta_data)
|
||||
except (json.JSONDecodeError, ValueError): pass
|
||||
meta['edited'] = True
|
||||
db_msg.meta_data = json.dumps(meta)
|
||||
|
||||
# Update in-memory history by matching _db_id
|
||||
for hmsg in session.history:
|
||||
hmeta = hmsg.metadata if isinstance(hmsg, ChatMessage) else hmsg.get('metadata')
|
||||
if isinstance(hmeta, dict) and hmeta.get('_db_id') == msg_id:
|
||||
if isinstance(hmsg, ChatMessage):
|
||||
hmsg.content = content
|
||||
hmsg.metadata['edited'] = True
|
||||
elif isinstance(hmsg, dict):
|
||||
hmsg['content'] = content
|
||||
hmsg['metadata']['edited'] = True
|
||||
break
|
||||
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
finally:
|
||||
db.close()
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Edit message error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/mark-stopped")
|
||||
async def mark_stopped(request: Request, session_id: str):
|
||||
"""Mark the last assistant message as stopped by user."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
# Find last assistant message and add stopped metadata
|
||||
for msg in reversed(session.history):
|
||||
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
|
||||
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
|
||||
if isinstance(msg, ChatMessage):
|
||||
if not msg.metadata:
|
||||
msg.metadata = {}
|
||||
msg.metadata['stopped'] = True
|
||||
if not msg.metadata.get('model'):
|
||||
msg.metadata['model'] = session.model
|
||||
else:
|
||||
if 'metadata' not in msg:
|
||||
msg['metadata'] = {}
|
||||
msg['metadata']['stopped'] = True
|
||||
if not msg['metadata'].get('model'):
|
||||
msg['metadata']['model'] = session.model
|
||||
break
|
||||
# Also update in DB
|
||||
db = SessionLocal()
|
||||
try:
|
||||
import json as _json
|
||||
db_messages = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id, DbChatMessage.role == 'assistant')
|
||||
.order_by(DbChatMessage.timestamp.desc())
|
||||
.first()
|
||||
)
|
||||
if db_messages:
|
||||
meta = {}
|
||||
if db_messages.meta_data:
|
||||
try:
|
||||
meta = _json.loads(db_messages.meta_data)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
meta['stopped'] = True
|
||||
if not meta.get('model'):
|
||||
meta['model'] = session.model
|
||||
db_messages.meta_data = _json.dumps(meta)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
session_manager.save_sessions()
|
||||
return {"status": "ok"}
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Mark stopped error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/update-last-meta")
|
||||
async def update_last_meta(request: Request, session_id: str):
|
||||
"""Merge metadata into the last assistant message (e.g. save variants)."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
meta_update = body.get("metadata", {})
|
||||
session = session_manager.get_session(session_id)
|
||||
|
||||
# Update in-memory
|
||||
for msg in reversed(session.history):
|
||||
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
|
||||
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
|
||||
if isinstance(msg, ChatMessage):
|
||||
if not msg.metadata:
|
||||
msg.metadata = {}
|
||||
msg.metadata.update(meta_update)
|
||||
else:
|
||||
if 'metadata' not in msg:
|
||||
msg['metadata'] = {}
|
||||
msg['metadata'].update(meta_update)
|
||||
break
|
||||
|
||||
# Update in DB
|
||||
db = SessionLocal()
|
||||
try:
|
||||
import json as _json
|
||||
db_msg = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id, DbChatMessage.role == 'assistant')
|
||||
.order_by(DbChatMessage.timestamp.desc())
|
||||
.first()
|
||||
)
|
||||
if db_msg:
|
||||
meta = {}
|
||||
if db_msg.meta_data:
|
||||
try: meta = _json.loads(db_msg.meta_data)
|
||||
except (json.JSONDecodeError, ValueError): pass
|
||||
meta.update(meta_update)
|
||||
db_msg.meta_data = _json.dumps(meta)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
session_manager.save_sessions()
|
||||
return {"status": "ok"}
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Update last meta error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/merge-last-assistant")
|
||||
async def merge_last_assistant(request: Request, session_id: str):
|
||||
"""Merge the last two assistant messages into one (for continue)."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
separator = body.get("separator", "\n\n")
|
||||
session = session_manager.get_session(session_id)
|
||||
|
||||
# Find last two assistant messages in-memory
|
||||
ai_indices = []
|
||||
for i, msg in enumerate(session.history):
|
||||
role = msg.role if isinstance(msg, ChatMessage) else msg.get('role', '')
|
||||
if role == 'assistant':
|
||||
ai_indices.append(i)
|
||||
|
||||
if len(ai_indices) < 2:
|
||||
return {"status": "ok", "merged": False}
|
||||
|
||||
idx1, idx2 = ai_indices[-2], ai_indices[-1]
|
||||
msg1, msg2 = session.history[idx1], session.history[idx2]
|
||||
|
||||
content1 = msg1.content if isinstance(msg1, ChatMessage) else msg1.get('content', '')
|
||||
content2 = msg2.content if isinstance(msg2, ChatMessage) else msg2.get('content', '')
|
||||
merged_content = content1 + separator + content2
|
||||
|
||||
# Merge metadata
|
||||
meta1 = (msg1.metadata if isinstance(msg1, ChatMessage) else msg1.get('metadata')) or {}
|
||||
meta2 = (msg2.metadata if isinstance(msg2, ChatMessage) else msg2.get('metadata')) or {}
|
||||
merged_meta = {**meta1, **meta2}
|
||||
merged_meta.pop('stopped', None) # no longer stopped after continue
|
||||
|
||||
# Update first message, remove second
|
||||
if isinstance(msg1, ChatMessage):
|
||||
msg1.content = merged_content
|
||||
msg1.metadata = merged_meta
|
||||
else:
|
||||
msg1['content'] = merged_content
|
||||
msg1['metadata'] = merged_meta
|
||||
|
||||
# Also remove the hidden "continue" user message between them if present
|
||||
# It's the message at idx2-1 if it's a user message with continue text
|
||||
remove_indices = [idx2]
|
||||
if idx2 - 1 > idx1:
|
||||
between = session.history[idx2 - 1]
|
||||
between_role = between.role if isinstance(between, ChatMessage) else between.get('role', '')
|
||||
between_content = between.content if isinstance(between, ChatMessage) else between.get('content', '')
|
||||
if between_role == 'user' and 'previous response was interrupted' in between_content:
|
||||
remove_indices.insert(0, idx2 - 1)
|
||||
|
||||
for ri in sorted(remove_indices, reverse=True):
|
||||
session.history.pop(ri)
|
||||
|
||||
# Update DB
|
||||
db = SessionLocal()
|
||||
try:
|
||||
import json as _json
|
||||
db_messages = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.order_by(DbChatMessage.timestamp)
|
||||
.all()
|
||||
)
|
||||
# Find last two assistant messages in DB
|
||||
ai_db = [(i, m) for i, m in enumerate(db_messages) if m.role == 'assistant']
|
||||
if len(ai_db) >= 2:
|
||||
(_, db1), (_, db2) = ai_db[-2], ai_db[-1]
|
||||
db1.content = merged_content
|
||||
db1.meta_data = _json.dumps(merged_meta)
|
||||
|
||||
# Mirror the in-memory deletion: remove the second assistant
|
||||
# message and ONLY the "continue" user message between them
|
||||
# (not arbitrary tool/system/user rows). The old
|
||||
# range-delete destroyed every row between the two assistant
|
||||
# messages, desyncing the DB from the in-memory history.
|
||||
for _row in _merge_continue_rows_to_delete(db_messages, db1, db2):
|
||||
db.delete(_row)
|
||||
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
session_manager.save_sessions()
|
||||
return {"status": "ok", "merged": True}
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Merge assistant error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/fork")
|
||||
async def fork_session(request: Request, session_id: str):
|
||||
"""Create a new session with messages copied up to keep_count."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
keep_count = body.get("keep_count", 0)
|
||||
|
||||
# Get the source session
|
||||
source = session_manager.sessions.get(session_id)
|
||||
if not source:
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
# Create new session
|
||||
new_id = str(uuid.uuid4())
|
||||
fork_name = f"\u2ADD {source.name}"
|
||||
new_session = session_manager.create_session(
|
||||
session_id=new_id,
|
||||
name=fork_name,
|
||||
endpoint_url=source.endpoint_url,
|
||||
model=source.model,
|
||||
rag=False,
|
||||
owner=getattr(source, 'owner', None),
|
||||
)
|
||||
|
||||
# Copy messages up to keep_count
|
||||
msgs_to_copy = source.history[:keep_count]
|
||||
for msg in msgs_to_copy:
|
||||
# Copy the metadata dict. Sharing it would let the fork's
|
||||
# persistence (add_message -> _persist_message stamps
|
||||
# _db_id/timestamp onto the dict) mutate the SOURCE session's
|
||||
# in-memory messages, corrupting their _db_id and breaking
|
||||
# edit/delete-by-id on the original conversation.
|
||||
meta = dict(msg.metadata) if isinstance(msg.metadata, dict) else None
|
||||
new_session.add_message(ChatMessage(msg.role, msg.content, meta))
|
||||
try:
|
||||
from src.event_bus import fire_event
|
||||
fire_event("session_created", getattr(source, 'owner', None))
|
||||
except Exception:
|
||||
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"id": new_id,
|
||||
"name": fork_name,
|
||||
"kept": len(msgs_to_copy),
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Fork error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.get("/api/conversations/topics")
|
||||
async def get_conversation_topics(request: Request) -> Dict[str, Any]:
|
||||
from src.auth_helpers import require_user
|
||||
user = require_user(request)
|
||||
try:
|
||||
return analyze_topics(session_manager, owner=user or None)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Topic analysis failed: {e}")
|
||||
|
||||
@router.post("/api/session/{session_id}/compact")
|
||||
async def compact_session(request: Request, session_id: str):
|
||||
"""Manually trigger context compaction for a session."""
|
||||
_verify_session_owner(request, session_id)
|
||||
from src.auth_helpers import effective_user
|
||||
owner = effective_user(request)
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
_reject_compact_during_active_run(session_id)
|
||||
|
||||
try:
|
||||
from src.model_context import estimate_tokens, get_context_length
|
||||
from src.llm_core import llm_call_async
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
if len(session.history) < 6:
|
||||
return {"status": "ok", "message": "Not enough messages to compact"}
|
||||
|
||||
ctx_len = get_context_length(session.endpoint_url, session.model)
|
||||
messages_before = session.get_context_messages()
|
||||
used_before = estimate_tokens(messages_before)
|
||||
pct_before = round((used_before / ctx_len) * 100, 1) if ctx_len else 0
|
||||
msg_count_before = len(session.history)
|
||||
|
||||
# Keep only last 4 messages, summarize the rest
|
||||
keep_count = 4
|
||||
older = session.history[:-keep_count]
|
||||
recent = session.history[-keep_count:]
|
||||
|
||||
# Build text to summarize
|
||||
convo_text = "\n".join(
|
||||
f"{_message_role(m).upper()}: "
|
||||
f"{_message_text(m)[:2000]}"
|
||||
for m in older
|
||||
)
|
||||
|
||||
# Use utility model if available
|
||||
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
|
||||
compact_url = util_url or session.endpoint_url
|
||||
compact_model = util_model or session.model
|
||||
compact_headers = util_headers if util_url else session.headers
|
||||
|
||||
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
|
||||
compaction_count = sum(1 for m in session.history if isinstance(m, ChatMessage) and "[Conversation summary" in (m.content or ""))
|
||||
sys_prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace("{count}", str(len(older))).replace("{n}", str(compaction_count + 1))
|
||||
summary = await llm_call_async(
|
||||
compact_url, compact_model,
|
||||
[
|
||||
{"role": "system", "content": sys_prompt},
|
||||
{"role": "user", "content": convo_text},
|
||||
],
|
||||
temperature=0.2, max_tokens=1024,
|
||||
headers=compact_headers, timeout=30,
|
||||
)
|
||||
|
||||
# Replace session history: summary as system message + recent messages
|
||||
# System message holds the full summary for AI context
|
||||
system_summary = ChatMessage(
|
||||
role="system",
|
||||
content=f"[Conversation summary — {len(older)} earlier messages were compacted]\n\n{summary}",
|
||||
metadata={"compacted": True, "hidden": True},
|
||||
)
|
||||
# Visible assistant message just shows stats
|
||||
summary_msg = ChatMessage(
|
||||
role="assistant",
|
||||
content=f"**Conversation compacted** — {len(older)} messages summarized, {len(recent)} kept.",
|
||||
metadata={"compacted": True, "messages_removed": len(older)},
|
||||
)
|
||||
new_history = [system_summary, summary_msg] + list(recent)
|
||||
session.history = new_history
|
||||
session.message_count = len(session.history)
|
||||
logger.info(f"Compact: session {session_id} history now has {len(session.history)} messages (was {msg_count_before})")
|
||||
|
||||
# Update DB: delete old messages, insert summary
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_msgs = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
|
||||
# Delete all but the last keep_count
|
||||
for m in db_msgs[:-keep_count]:
|
||||
db.delete(m)
|
||||
|
||||
# Insert system summary (hidden, for AI context) and visible summary
|
||||
import json as _json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
db_sys_summary = DbChatMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=session_id,
|
||||
role="system",
|
||||
content=system_summary.content,
|
||||
meta_data=_json.dumps(system_summary.metadata),
|
||||
timestamp=now,
|
||||
)
|
||||
db.add(db_sys_summary)
|
||||
db_summary = DbChatMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
content=summary_msg.content,
|
||||
meta_data=_json.dumps(summary_msg.metadata),
|
||||
timestamp=now,
|
||||
)
|
||||
db.add(db_summary)
|
||||
|
||||
# Update session record
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.message_count = len(session.history)
|
||||
db_session.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
session_manager.save_sessions()
|
||||
|
||||
used_after = estimate_tokens(session.get_context_messages())
|
||||
pct_after = round((used_after / ctx_len) * 100, 1) if ctx_len else 0
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Compacted: {msg_count_before} msgs → {len(session.history)} msgs ({pct_before}% → {pct_after}%)",
|
||||
"before": pct_before,
|
||||
"after": pct_after,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Manual compact error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
return router
|
||||
+13
-764
@@ -1,768 +1,17 @@
|
||||
"""History routes — session history, truncation, fork, conversation topics."""
|
||||
"""Backward-compat shim — canonical location is routes/history/history_routes.py.
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Any, Optional
|
||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||
that ``import routes.history_routes``, ``from routes.history_routes import X``,
|
||||
``importlib.import_module("routes.history_routes")``, and the
|
||||
``import ... as history_routes`` + ``monkeypatch.setattr(history_routes, ...)``
|
||||
pattern used by test_history_compact_tool_calls.py / test_fork_session_metadata.py
|
||||
all operate on the *same* object the application actually uses. Keeps existing
|
||||
import paths working after slice 2d (#4082/#4071). Source-introspection tests
|
||||
read the canonical file by path.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
import sys as _sys
|
||||
|
||||
from core.models import ChatMessage
|
||||
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
||||
from src.topic_analyzer import analyze_topics
|
||||
from routes.session_routes import (
|
||||
_message_role,
|
||||
_message_text,
|
||||
_reject_compact_during_active_run,
|
||||
_verify_session_owner,
|
||||
)
|
||||
from routes.history import history_routes as _canonical # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HISTORY_INLINE_MEDIA_THRESHOLD = 200_000
|
||||
_DATA_IMAGE_RE = re.compile(r"data:image/[^;,\"]+;base64,[A-Za-z0-9+/=\s]+")
|
||||
|
||||
|
||||
def _history_display_content(content: Any) -> Any:
|
||||
"""Return a lightweight browser-display copy of stored message content.
|
||||
|
||||
Older multimodal user messages may be persisted as a JSON *string*
|
||||
containing image_url blocks with inline base64 image bytes. Those bytes are
|
||||
needed for model calls when the turn is first sent, but they should not be
|
||||
sent back through /api/history every time the user opens the chat. The
|
||||
attachment metadata already carries file ids/names for the UI cards.
|
||||
"""
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
omitted_media = 0
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") == "text":
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
text_parts.append(text)
|
||||
elif block.get("type") in {"image_url", "input_image", "audio", "input_audio"}:
|
||||
omitted_media += 1
|
||||
text = "\n".join(text_parts).strip()
|
||||
if omitted_media and not text:
|
||||
return f"[{omitted_media} media attachment{'s' if omitted_media != 1 else ''} omitted from history view]"
|
||||
return text
|
||||
|
||||
if not isinstance(content, str):
|
||||
return content
|
||||
if len(content) < _HISTORY_INLINE_MEDIA_THRESHOLD and "data:image/" not in content:
|
||||
return content
|
||||
|
||||
stripped = content.lstrip()
|
||||
if stripped.startswith("["):
|
||||
try:
|
||||
blocks = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
blocks = None
|
||||
if isinstance(blocks, list):
|
||||
text_parts = []
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") == "text":
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
text_parts.append(text)
|
||||
if text_parts:
|
||||
return "\n".join(text_parts).strip()
|
||||
|
||||
if "data:image/" in content:
|
||||
return _DATA_IMAGE_RE.sub("[inline image omitted from history view]", content)
|
||||
return content
|
||||
|
||||
|
||||
def _merge_continue_rows_to_delete(db_messages, db1, db2):
|
||||
"""DB rows to delete when merging the last two assistant messages.
|
||||
|
||||
Always the second assistant message (db2), plus ONLY the single
|
||||
intervening "continue" user message (the one carrying "previous response
|
||||
was interrupted") — matching the in-memory merge. The previous code
|
||||
deleted the whole index range between the two assistant rows, destroying
|
||||
any tool/system/user messages in between and desyncing the DB from the
|
||||
in-memory history.
|
||||
"""
|
||||
to_delete = [db2]
|
||||
i1 = next((i for i, m in enumerate(db_messages) if m is db1), None)
|
||||
i2 = next((i for i, m in enumerate(db_messages) if m is db2), None)
|
||||
if i1 is not None and i2 is not None and i2 - 1 > i1:
|
||||
between = db_messages[i2 - 1]
|
||||
if getattr(between, "role", "") == "user" and "previous response was interrupted" in (getattr(between, "content", "") or ""):
|
||||
to_delete.append(between)
|
||||
return to_delete
|
||||
|
||||
|
||||
def setup_history_routes(session_manager) -> APIRouter:
|
||||
router = APIRouter(tags=["history"])
|
||||
|
||||
def _db_history_entry(m: DbChatMessage) -> Dict[str, Any]:
|
||||
entry = {"role": m.role, "content": _history_display_content(m.content)}
|
||||
meta = {}
|
||||
if m.meta_data:
|
||||
try:
|
||||
meta = json.loads(m.meta_data) or {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
meta = {}
|
||||
if m.timestamp and "timestamp" not in meta:
|
||||
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
||||
if meta:
|
||||
entry["metadata"] = meta
|
||||
return entry
|
||||
|
||||
@router.get("/api/history/{session_id}")
|
||||
async def get_session_history(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
_verify_session_owner(request, session_id)
|
||||
if limit is not None:
|
||||
page_limit = max(1, min(int(limit), 100))
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session is None:
|
||||
raise HTTPException(404, f"Session '{session_id}' not found")
|
||||
|
||||
total = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.count()
|
||||
)
|
||||
page_offset = int(offset) if offset is not None else max(total - page_limit, 0)
|
||||
page_offset = max(0, min(page_offset, total))
|
||||
rows = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.order_by(DbChatMessage.timestamp)
|
||||
.offset(page_offset)
|
||||
.limit(page_limit)
|
||||
.all()
|
||||
)
|
||||
history_dict = [
|
||||
entry for entry in (_db_history_entry(m) for m in rows)
|
||||
if not (entry.get("metadata") or {}).get("hidden")
|
||||
]
|
||||
return {
|
||||
"history": history_dict,
|
||||
"model": db_session.model,
|
||||
"endpoint_url": db_session.endpoint_url,
|
||||
"name": db_session.name,
|
||||
"offset": page_offset,
|
||||
"limit": page_limit,
|
||||
"total": total,
|
||||
"has_more_before": page_offset > 0,
|
||||
"has_more_after": page_offset + len(rows) < total,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, f"Session '{session_id}' not found")
|
||||
|
||||
history_dict = []
|
||||
for msg in session.history:
|
||||
if isinstance(msg, ChatMessage):
|
||||
# Skip hidden messages (e.g. compaction summaries for AI context)
|
||||
if msg.metadata and msg.metadata.get("hidden"):
|
||||
continue
|
||||
entry = {"role": msg.role, "content": _history_display_content(msg.content)}
|
||||
if msg.metadata:
|
||||
entry["metadata"] = msg.metadata
|
||||
history_dict.append(entry)
|
||||
elif isinstance(msg, dict):
|
||||
if msg.get("metadata", {}).get("hidden"):
|
||||
continue
|
||||
entry = {
|
||||
"role": msg.get("role", ""),
|
||||
"content": _history_display_content(msg.get("content", "")),
|
||||
}
|
||||
if msg.get("metadata"):
|
||||
entry["metadata"] = msg["metadata"]
|
||||
history_dict.append(entry)
|
||||
|
||||
# Fallback: load from DB if in-memory is empty
|
||||
if not history_dict:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_messages = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.order_by(DbChatMessage.timestamp)
|
||||
.all()
|
||||
)
|
||||
db_history = []
|
||||
for m in db_messages:
|
||||
db_history.append(_db_history_entry(m))
|
||||
if db_history:
|
||||
# Rebuild in-memory history from the full set so hidden
|
||||
# messages (e.g. compaction summaries) are kept for AI context.
|
||||
session.history = [
|
||||
ChatMessage(role=m["role"], content=m["content"], metadata=m.get("metadata"))
|
||||
for m in db_history
|
||||
]
|
||||
# Response excludes hidden messages, matching the in-memory path.
|
||||
history_dict = [
|
||||
m for m in db_history
|
||||
if not (m.get("metadata") or {}).get("hidden")
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"DB fallback failed for {session_id}: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {
|
||||
"history": history_dict,
|
||||
"model": session.model,
|
||||
"endpoint_url": session.endpoint_url,
|
||||
"name": session.name,
|
||||
}
|
||||
|
||||
@router.post("/api/session/{session_id}/truncate")
|
||||
async def truncate_session(request: Request, session_id: str):
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
keep_count = body.get("keep_count", 0)
|
||||
result = session_manager.truncate_messages(session_id, keep_count)
|
||||
return {"status": "ok", "kept": keep_count, "truncated": result}
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Truncate error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/message")
|
||||
async def add_message(request: Request, session_id: str):
|
||||
"""Add a message to a session (for slash command persistence)."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
role = body.get("role", "assistant")
|
||||
content = body.get("content", "")
|
||||
if not content:
|
||||
raise HTTPException(400, "content is required")
|
||||
msg = ChatMessage(role=role, content=content, metadata=body.get("metadata"))
|
||||
session_manager.add_message(session_id, msg)
|
||||
return {"status": "ok"}
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
@router.post("/api/session/{session_id}/delete-messages")
|
||||
async def delete_messages(request: Request, session_id: str):
|
||||
"""Delete specific messages by DB ID (or legacy index)."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
msg_ids = body.get("msg_ids", [])
|
||||
indices = body.get("indices") # legacy fallback
|
||||
|
||||
session = session_manager.get_session(session_id)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if msg_ids:
|
||||
# New ID-based delete
|
||||
deleted = 0
|
||||
for mid in msg_ids:
|
||||
db_msg = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.id == mid,
|
||||
DbChatMessage.session_id == session_id,
|
||||
).first()
|
||||
if db_msg:
|
||||
db.delete(db_msg)
|
||||
deleted += 1
|
||||
|
||||
# Remove from in-memory history by matching _db_id
|
||||
def _get_db_id(m):
|
||||
meta = m.metadata if isinstance(m, ChatMessage) else (m.get('metadata') if isinstance(m, dict) else None)
|
||||
return meta.get('_db_id') if isinstance(meta, dict) else None
|
||||
session.history = [m for m in session.history if _get_db_id(m) not in msg_ids]
|
||||
elif indices:
|
||||
# Legacy index-based delete
|
||||
indices = sorted(indices, reverse=True)
|
||||
db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
|
||||
deleted = 0
|
||||
for idx in indices:
|
||||
if 0 <= idx < len(db_messages):
|
||||
db.delete(db_messages[idx])
|
||||
deleted += 1
|
||||
if 0 <= idx < len(session.history):
|
||||
session.history.pop(idx)
|
||||
else:
|
||||
return {"status": "ok", "deleted": 0}
|
||||
|
||||
session.message_count = len(session.history)
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.message_count = len(session.history)
|
||||
from datetime import datetime, timezone
|
||||
db_session.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
return {"status": "ok", "deleted": deleted}
|
||||
finally:
|
||||
db.close()
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Delete messages error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/edit-message")
|
||||
async def edit_message(request: Request, session_id: str):
|
||||
"""Edit the content of a message by its database ID."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
msg_id = body.get("msg_id")
|
||||
content = body.get("content")
|
||||
if not msg_id or content is None:
|
||||
raise HTTPException(400, "msg_id and content are required")
|
||||
|
||||
session = session_manager.get_session(session_id)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_msg = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.id == msg_id,
|
||||
DbChatMessage.session_id == session_id,
|
||||
).first()
|
||||
if not db_msg:
|
||||
raise HTTPException(404, "Message not found")
|
||||
|
||||
db_msg.content = content
|
||||
meta = {}
|
||||
if db_msg.meta_data:
|
||||
try: meta = json.loads(db_msg.meta_data)
|
||||
except (json.JSONDecodeError, ValueError): pass
|
||||
meta['edited'] = True
|
||||
db_msg.meta_data = json.dumps(meta)
|
||||
|
||||
# Update in-memory history by matching _db_id
|
||||
for hmsg in session.history:
|
||||
hmeta = hmsg.metadata if isinstance(hmsg, ChatMessage) else hmsg.get('metadata')
|
||||
if isinstance(hmeta, dict) and hmeta.get('_db_id') == msg_id:
|
||||
if isinstance(hmsg, ChatMessage):
|
||||
hmsg.content = content
|
||||
hmsg.metadata['edited'] = True
|
||||
elif isinstance(hmsg, dict):
|
||||
hmsg['content'] = content
|
||||
hmsg['metadata']['edited'] = True
|
||||
break
|
||||
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
finally:
|
||||
db.close()
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Edit message error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/mark-stopped")
|
||||
async def mark_stopped(request: Request, session_id: str):
|
||||
"""Mark the last assistant message as stopped by user."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
# Find last assistant message and add stopped metadata
|
||||
for msg in reversed(session.history):
|
||||
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
|
||||
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
|
||||
if isinstance(msg, ChatMessage):
|
||||
if not msg.metadata:
|
||||
msg.metadata = {}
|
||||
msg.metadata['stopped'] = True
|
||||
if not msg.metadata.get('model'):
|
||||
msg.metadata['model'] = session.model
|
||||
else:
|
||||
if 'metadata' not in msg:
|
||||
msg['metadata'] = {}
|
||||
msg['metadata']['stopped'] = True
|
||||
if not msg['metadata'].get('model'):
|
||||
msg['metadata']['model'] = session.model
|
||||
break
|
||||
# Also update in DB
|
||||
db = SessionLocal()
|
||||
try:
|
||||
import json as _json
|
||||
db_messages = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id, DbChatMessage.role == 'assistant')
|
||||
.order_by(DbChatMessage.timestamp.desc())
|
||||
.first()
|
||||
)
|
||||
if db_messages:
|
||||
meta = {}
|
||||
if db_messages.meta_data:
|
||||
try:
|
||||
meta = _json.loads(db_messages.meta_data)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
meta['stopped'] = True
|
||||
if not meta.get('model'):
|
||||
meta['model'] = session.model
|
||||
db_messages.meta_data = _json.dumps(meta)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
session_manager.save_sessions()
|
||||
return {"status": "ok"}
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Mark stopped error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/update-last-meta")
|
||||
async def update_last_meta(request: Request, session_id: str):
|
||||
"""Merge metadata into the last assistant message (e.g. save variants)."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
meta_update = body.get("metadata", {})
|
||||
session = session_manager.get_session(session_id)
|
||||
|
||||
# Update in-memory
|
||||
for msg in reversed(session.history):
|
||||
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
|
||||
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
|
||||
if isinstance(msg, ChatMessage):
|
||||
if not msg.metadata:
|
||||
msg.metadata = {}
|
||||
msg.metadata.update(meta_update)
|
||||
else:
|
||||
if 'metadata' not in msg:
|
||||
msg['metadata'] = {}
|
||||
msg['metadata'].update(meta_update)
|
||||
break
|
||||
|
||||
# Update in DB
|
||||
db = SessionLocal()
|
||||
try:
|
||||
import json as _json
|
||||
db_msg = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id, DbChatMessage.role == 'assistant')
|
||||
.order_by(DbChatMessage.timestamp.desc())
|
||||
.first()
|
||||
)
|
||||
if db_msg:
|
||||
meta = {}
|
||||
if db_msg.meta_data:
|
||||
try: meta = _json.loads(db_msg.meta_data)
|
||||
except (json.JSONDecodeError, ValueError): pass
|
||||
meta.update(meta_update)
|
||||
db_msg.meta_data = _json.dumps(meta)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
session_manager.save_sessions()
|
||||
return {"status": "ok"}
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Update last meta error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/merge-last-assistant")
|
||||
async def merge_last_assistant(request: Request, session_id: str):
|
||||
"""Merge the last two assistant messages into one (for continue)."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
separator = body.get("separator", "\n\n")
|
||||
session = session_manager.get_session(session_id)
|
||||
|
||||
# Find last two assistant messages in-memory
|
||||
ai_indices = []
|
||||
for i, msg in enumerate(session.history):
|
||||
role = msg.role if isinstance(msg, ChatMessage) else msg.get('role', '')
|
||||
if role == 'assistant':
|
||||
ai_indices.append(i)
|
||||
|
||||
if len(ai_indices) < 2:
|
||||
return {"status": "ok", "merged": False}
|
||||
|
||||
idx1, idx2 = ai_indices[-2], ai_indices[-1]
|
||||
msg1, msg2 = session.history[idx1], session.history[idx2]
|
||||
|
||||
content1 = msg1.content if isinstance(msg1, ChatMessage) else msg1.get('content', '')
|
||||
content2 = msg2.content if isinstance(msg2, ChatMessage) else msg2.get('content', '')
|
||||
merged_content = content1 + separator + content2
|
||||
|
||||
# Merge metadata
|
||||
meta1 = (msg1.metadata if isinstance(msg1, ChatMessage) else msg1.get('metadata')) or {}
|
||||
meta2 = (msg2.metadata if isinstance(msg2, ChatMessage) else msg2.get('metadata')) or {}
|
||||
merged_meta = {**meta1, **meta2}
|
||||
merged_meta.pop('stopped', None) # no longer stopped after continue
|
||||
|
||||
# Update first message, remove second
|
||||
if isinstance(msg1, ChatMessage):
|
||||
msg1.content = merged_content
|
||||
msg1.metadata = merged_meta
|
||||
else:
|
||||
msg1['content'] = merged_content
|
||||
msg1['metadata'] = merged_meta
|
||||
|
||||
# Also remove the hidden "continue" user message between them if present
|
||||
# It's the message at idx2-1 if it's a user message with continue text
|
||||
remove_indices = [idx2]
|
||||
if idx2 - 1 > idx1:
|
||||
between = session.history[idx2 - 1]
|
||||
between_role = between.role if isinstance(between, ChatMessage) else between.get('role', '')
|
||||
between_content = between.content if isinstance(between, ChatMessage) else between.get('content', '')
|
||||
if between_role == 'user' and 'previous response was interrupted' in between_content:
|
||||
remove_indices.insert(0, idx2 - 1)
|
||||
|
||||
for ri in sorted(remove_indices, reverse=True):
|
||||
session.history.pop(ri)
|
||||
|
||||
# Update DB
|
||||
db = SessionLocal()
|
||||
try:
|
||||
import json as _json
|
||||
db_messages = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.order_by(DbChatMessage.timestamp)
|
||||
.all()
|
||||
)
|
||||
# Find last two assistant messages in DB
|
||||
ai_db = [(i, m) for i, m in enumerate(db_messages) if m.role == 'assistant']
|
||||
if len(ai_db) >= 2:
|
||||
(_, db1), (_, db2) = ai_db[-2], ai_db[-1]
|
||||
db1.content = merged_content
|
||||
db1.meta_data = _json.dumps(merged_meta)
|
||||
|
||||
# Mirror the in-memory deletion: remove the second assistant
|
||||
# message and ONLY the "continue" user message between them
|
||||
# (not arbitrary tool/system/user rows). The old
|
||||
# range-delete destroyed every row between the two assistant
|
||||
# messages, desyncing the DB from the in-memory history.
|
||||
for _row in _merge_continue_rows_to_delete(db_messages, db1, db2):
|
||||
db.delete(_row)
|
||||
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
session_manager.save_sessions()
|
||||
return {"status": "ok", "merged": True}
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Merge assistant error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/fork")
|
||||
async def fork_session(request: Request, session_id: str):
|
||||
"""Create a new session with messages copied up to keep_count."""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
keep_count = body.get("keep_count", 0)
|
||||
|
||||
# Get the source session
|
||||
source = session_manager.sessions.get(session_id)
|
||||
if not source:
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
# Create new session
|
||||
new_id = str(uuid.uuid4())
|
||||
fork_name = f"\u2ADD {source.name}"
|
||||
new_session = session_manager.create_session(
|
||||
session_id=new_id,
|
||||
name=fork_name,
|
||||
endpoint_url=source.endpoint_url,
|
||||
model=source.model,
|
||||
rag=False,
|
||||
owner=getattr(source, 'owner', None),
|
||||
)
|
||||
|
||||
# Copy messages up to keep_count
|
||||
msgs_to_copy = source.history[:keep_count]
|
||||
for msg in msgs_to_copy:
|
||||
# Copy the metadata dict. Sharing it would let the fork's
|
||||
# persistence (add_message -> _persist_message stamps
|
||||
# _db_id/timestamp onto the dict) mutate the SOURCE session's
|
||||
# in-memory messages, corrupting their _db_id and breaking
|
||||
# edit/delete-by-id on the original conversation.
|
||||
meta = dict(msg.metadata) if isinstance(msg.metadata, dict) else None
|
||||
new_session.add_message(ChatMessage(msg.role, msg.content, meta))
|
||||
try:
|
||||
from src.event_bus import fire_event
|
||||
fire_event("session_created", getattr(source, 'owner', None))
|
||||
except Exception:
|
||||
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"id": new_id,
|
||||
"name": fork_name,
|
||||
"kept": len(msgs_to_copy),
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Fork error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.get("/api/conversations/topics")
|
||||
async def get_conversation_topics(request: Request) -> Dict[str, Any]:
|
||||
from src.auth_helpers import require_user
|
||||
user = require_user(request)
|
||||
try:
|
||||
return analyze_topics(session_manager, owner=user or None)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Topic analysis failed: {e}")
|
||||
|
||||
@router.post("/api/session/{session_id}/compact")
|
||||
async def compact_session(request: Request, session_id: str):
|
||||
"""Manually trigger context compaction for a session."""
|
||||
_verify_session_owner(request, session_id)
|
||||
from src.auth_helpers import effective_user
|
||||
owner = effective_user(request)
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
_reject_compact_during_active_run(session_id)
|
||||
|
||||
try:
|
||||
from src.model_context import estimate_tokens, get_context_length
|
||||
from src.llm_core import llm_call_async
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
if len(session.history) < 6:
|
||||
return {"status": "ok", "message": "Not enough messages to compact"}
|
||||
|
||||
ctx_len = get_context_length(session.endpoint_url, session.model)
|
||||
messages_before = session.get_context_messages()
|
||||
used_before = estimate_tokens(messages_before)
|
||||
pct_before = round((used_before / ctx_len) * 100, 1) if ctx_len else 0
|
||||
msg_count_before = len(session.history)
|
||||
|
||||
# Keep only last 4 messages, summarize the rest
|
||||
keep_count = 4
|
||||
older = session.history[:-keep_count]
|
||||
recent = session.history[-keep_count:]
|
||||
|
||||
# Build text to summarize
|
||||
convo_text = "\n".join(
|
||||
f"{_message_role(m).upper()}: "
|
||||
f"{_message_text(m)[:2000]}"
|
||||
for m in older
|
||||
)
|
||||
|
||||
# Use utility model if available
|
||||
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
|
||||
compact_url = util_url or session.endpoint_url
|
||||
compact_model = util_model or session.model
|
||||
compact_headers = util_headers if util_url else session.headers
|
||||
|
||||
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
|
||||
compaction_count = sum(1 for m in session.history if isinstance(m, ChatMessage) and "[Conversation summary" in (m.content or ""))
|
||||
sys_prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace("{count}", str(len(older))).replace("{n}", str(compaction_count + 1))
|
||||
summary = await llm_call_async(
|
||||
compact_url, compact_model,
|
||||
[
|
||||
{"role": "system", "content": sys_prompt},
|
||||
{"role": "user", "content": convo_text},
|
||||
],
|
||||
temperature=0.2, max_tokens=1024,
|
||||
headers=compact_headers, timeout=30,
|
||||
)
|
||||
|
||||
# Replace session history: summary as system message + recent messages
|
||||
# System message holds the full summary for AI context
|
||||
system_summary = ChatMessage(
|
||||
role="system",
|
||||
content=f"[Conversation summary — {len(older)} earlier messages were compacted]\n\n{summary}",
|
||||
metadata={"compacted": True, "hidden": True},
|
||||
)
|
||||
# Visible assistant message just shows stats
|
||||
summary_msg = ChatMessage(
|
||||
role="assistant",
|
||||
content=f"**Conversation compacted** — {len(older)} messages summarized, {len(recent)} kept.",
|
||||
metadata={"compacted": True, "messages_removed": len(older)},
|
||||
)
|
||||
new_history = [system_summary, summary_msg] + list(recent)
|
||||
session.history = new_history
|
||||
session.message_count = len(session.history)
|
||||
logger.info(f"Compact: session {session_id} history now has {len(session.history)} messages (was {msg_count_before})")
|
||||
|
||||
# Update DB: delete old messages, insert summary
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_msgs = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
|
||||
# Delete all but the last keep_count
|
||||
for m in db_msgs[:-keep_count]:
|
||||
db.delete(m)
|
||||
|
||||
# Insert system summary (hidden, for AI context) and visible summary
|
||||
import json as _json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
db_sys_summary = DbChatMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=session_id,
|
||||
role="system",
|
||||
content=system_summary.content,
|
||||
meta_data=_json.dumps(system_summary.metadata),
|
||||
timestamp=now,
|
||||
)
|
||||
db.add(db_sys_summary)
|
||||
db_summary = DbChatMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
content=summary_msg.content,
|
||||
meta_data=_json.dumps(summary_msg.metadata),
|
||||
timestamp=now,
|
||||
)
|
||||
db.add(db_summary)
|
||||
|
||||
# Update session record
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.message_count = len(session.history)
|
||||
db_session.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
session_manager.save_sessions()
|
||||
|
||||
used_after = estimate_tokens(session.get_context_messages())
|
||||
pct_after = round((used_after / ctx_len) * 100, 1) if ctx_len else 0
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Compacted: {msg_count_before} msgs → {len(session.history)} msgs ({pct_before}% → {pct_after}%)",
|
||||
"before": pct_before,
|
||||
"after": pct_after,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Manual compact error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
return router
|
||||
_sys.modules[__name__] = _canonical
|
||||
|
||||
+12
-3
@@ -191,7 +191,7 @@ def setup_hwfit_routes():
|
||||
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
|
||||
|
||||
@router.get("/models")
|
||||
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
|
||||
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, refresh_catalog: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
|
||||
"""Rank LLM models against detected hardware and return scored results.
|
||||
gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the
|
||||
active group). gpu_group: index into system.gpu_groups (the homogeneous
|
||||
@@ -200,11 +200,17 @@ def setup_hwfit_routes():
|
||||
fresh=true bypasses the hardware-detection cache."""
|
||||
from services.hwfit.hardware import detect_system
|
||||
from services.hwfit.fit import rank_models
|
||||
from services.hwfit.models import get_models, model_catalog_path
|
||||
from services.hwfit.models import get_models, model_catalog_path, refresh_dynamic_catalogs
|
||||
host, ssh_port = _validate_detection_target(host, ssh_port)
|
||||
system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh))
|
||||
if system.get("error"):
|
||||
return {"system": system, "models": [], "error": system["error"]}
|
||||
catalog_refresh = None
|
||||
if refresh_catalog:
|
||||
try:
|
||||
catalog_refresh = refresh_dynamic_catalogs(force=True)
|
||||
except Exception as e:
|
||||
catalog_refresh = {"error": str(e)}
|
||||
if not get_models():
|
||||
return {
|
||||
"system": system,
|
||||
@@ -304,7 +310,10 @@ def setup_hwfit_routes():
|
||||
rank_kwargs.pop("target_context", None)
|
||||
rank_kwargs.pop("fit_only", None)
|
||||
results = rank_models(system, **rank_kwargs)
|
||||
return {"system": system, "models": results}
|
||||
payload = {"system": system, "models": results}
|
||||
if catalog_refresh is not None:
|
||||
payload["catalog_refresh"] = catalog_refresh
|
||||
return payload
|
||||
|
||||
@router.get("/profiles")
|
||||
def get_serve_profiles(model: str = "", model_path: str = "", host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, serve_weights_gb: float = 0.0, serve_quant: str = ""):
|
||||
|
||||
+42
-4
@@ -1152,6 +1152,36 @@ def _merge_model_ids(*lists):
|
||||
return out
|
||||
|
||||
|
||||
def _is_mlx_deepseek_v4_repo_id(model_id: str) -> bool:
|
||||
m = str(model_id or "").lower()
|
||||
return "mlx-community/deepseek-v4" in m
|
||||
|
||||
|
||||
def _is_mlx_deepseek_v4_shim_id(model_id: str) -> bool:
|
||||
m = str(model_id or "").lower()
|
||||
return "/.cache/odysseus/mlx-shims/deepseek-v4" in m
|
||||
|
||||
|
||||
def _filter_mlx_deepseek_v4_repo_when_shimmed(model_ids):
|
||||
"""Hide the broken MLX repo id when a launch-specific shim id is available.
|
||||
|
||||
mlx_lm.server may advertise the original HF repo id even though generation
|
||||
only works through Odysseus' sanitized local shim. Keep the shim as the
|
||||
submitted model id and remove the raw repo id from the picker/default list.
|
||||
"""
|
||||
ids = list(model_ids or [])
|
||||
has_shim = any(_is_mlx_deepseek_v4_shim_id(m) for m in ids)
|
||||
if not has_shim:
|
||||
return ids
|
||||
return [m for m in ids if not _is_mlx_deepseek_v4_repo_id(m)]
|
||||
|
||||
|
||||
def _model_display_name(model_id: str) -> str:
|
||||
if _is_mlx_deepseek_v4_shim_id(model_id):
|
||||
return str(model_id or "").rstrip("/").split("/")[-1] or "DeepSeek-V4-Flash-4bit"
|
||||
return str(model_id or "").split("/")[-1]
|
||||
|
||||
|
||||
def _visible_models(cached_models, hidden_models, pinned_models=None):
|
||||
"""Merge cached + pinned model IDs, then filter out hidden ones.
|
||||
|
||||
@@ -1165,6 +1195,7 @@ def _visible_models(cached_models, hidden_models, pinned_models=None):
|
||||
_normalize_model_ids(cached_models),
|
||||
_normalize_model_ids(pinned_models),
|
||||
)
|
||||
merged = _filter_mlx_deepseek_v4_repo_when_shimmed(merged)
|
||||
if not hidden_models:
|
||||
return merged
|
||||
hidden = set(_normalize_model_ids(hidden_models))
|
||||
@@ -1396,9 +1427,9 @@ def setup_model_routes(model_discovery):
|
||||
"port": 0,
|
||||
"url": chat_url,
|
||||
"models": curated,
|
||||
"models_display": [mid.split("/")[-1] for mid in curated],
|
||||
"models_display": [_model_display_name(mid) for mid in curated],
|
||||
"models_extra": extra,
|
||||
"models_extra_display": [mid.split("/")[-1] for mid in extra],
|
||||
"models_extra_display": [_model_display_name(mid) for mid in extra],
|
||||
"endpoint_id": ep.id,
|
||||
"endpoint_name": ep.name,
|
||||
"category": category,
|
||||
@@ -1426,7 +1457,7 @@ def setup_model_routes(model_discovery):
|
||||
return {"hosts": [], "items": items}
|
||||
|
||||
@router.get("/models")
|
||||
def api_models(request: Request, refresh: bool = False, background: bool = True):
|
||||
def api_models(request: Request, refresh: bool = False, background: bool = False):
|
||||
"""Get available models — per-user (caller sees only their endpoints +
|
||||
legacy/shared null-owner rows). Cached per-user for 30s."""
|
||||
# Require auth; "" is the unconfigured single-user mode, treated as
|
||||
@@ -1887,7 +1918,14 @@ def setup_model_routes(model_discovery):
|
||||
if api_key.strip() and not existing.api_key:
|
||||
existing.api_key = api_key.strip()
|
||||
changed = True
|
||||
if should_probe:
|
||||
# Keep duplicate endpoint registration cheap. This path is hit
|
||||
# by Cookbook/browser auto-register flows and can run while the
|
||||
# user is sending a chat message. Probing a stale LAN endpoint
|
||||
# here used to hold the request open for tens of seconds and
|
||||
# contend with session creation, making "send" feel blocked.
|
||||
# Explicit "require models" calls still probe; normal refresh
|
||||
# belongs to /model-endpoints/{id}/models or /probe.
|
||||
if require_model_list:
|
||||
probed_models = _probe_endpoint(
|
||||
base_url,
|
||||
(api_key.strip() or existing.api_key or None),
|
||||
|
||||
+16
-5
@@ -483,11 +483,22 @@ async def dispatch_reminder(
|
||||
api_key = intg.get("api_key", "")
|
||||
if api_key:
|
||||
hdrs["Authorization"] = f"Bearer {api_key}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
|
||||
ntfy_sent = resp.is_success
|
||||
if not ntfy_sent:
|
||||
ntfy_error = f"ntfy returned HTTP {resp.status_code}"
|
||||
# SSRF guard — same check (and env knob) as the webhook branch
|
||||
# above: link-local / metadata addresses are always rejected;
|
||||
# REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true also blocks RFC-1918
|
||||
# so a ntfy base_url can't be pointed at internal services.
|
||||
import os as _os
|
||||
from src.url_safety import check_outbound_url as _chk
|
||||
_block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true"
|
||||
_ok, _reason = _chk(f"{base}/{topic}", block_private=_block)
|
||||
if not _ok:
|
||||
ntfy_error = f"ntfy URL rejected: {_reason}"
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
|
||||
ntfy_sent = resp.is_success
|
||||
if not ntfy_sent:
|
||||
ntfy_error = f"ntfy returned HTTP {resp.status_code}"
|
||||
else:
|
||||
ntfy_error = "No enabled ntfy integration"
|
||||
except Exception as e:
|
||||
|
||||
@@ -21,23 +21,52 @@ from src.constants import DEEP_RESEARCH_DIR
|
||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||
|
||||
|
||||
def _confine_research_path(session_id: str) -> Path:
|
||||
"""Return the resolved Path for session_id's JSON inside DEEP_RESEARCH_DIR.
|
||||
|
||||
Validates the session ID format and asserts containment after symlink
|
||||
expansion. Raises HTTPException(400) on format failures, traversal
|
||||
attempts, absolute-path injection, and symlink escape so every caller
|
||||
gets a safe, confined path with no extra validation needed.
|
||||
"""
|
||||
def _validate_session_id(session_id: str) -> str:
|
||||
if not _SESSION_ID_RE.fullmatch(session_id):
|
||||
raise HTTPException(400, "Invalid session ID")
|
||||
root = Path(DEEP_RESEARCH_DIR).resolve()
|
||||
candidate = (root / f"{session_id}.json").resolve()
|
||||
raise HTTPException(400, "Invalid session ID format")
|
||||
return session_id
|
||||
|
||||
|
||||
def _research_storage_root() -> Path:
|
||||
return Path(DEEP_RESEARCH_DIR).resolve()
|
||||
|
||||
|
||||
def _find_research_path(session_id: str) -> Path | None:
|
||||
"""Find a persisted research file without deriving its path from input."""
|
||||
expected_name = f"{_validate_session_id(session_id)}.json"
|
||||
root = _research_storage_root()
|
||||
for stored_path in root.glob("*.json"):
|
||||
if stored_path.name != expected_name:
|
||||
continue
|
||||
resolved = stored_path.resolve()
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
except ValueError:
|
||||
return None
|
||||
if not resolved.is_file():
|
||||
return None
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def _require_research_path(session_id: str) -> Path:
|
||||
path = _find_research_path(session_id)
|
||||
if path is None:
|
||||
raise HTTPException(404, "Research not found")
|
||||
return path
|
||||
|
||||
|
||||
def _find_owned_research_path(session_id: str, user: str) -> Path | None:
|
||||
path = _find_research_path(session_id)
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid session ID")
|
||||
return candidate
|
||||
owner = json.loads(path.read_text(encoding="utf-8")).get("owner")
|
||||
except Exception:
|
||||
return None
|
||||
if owner != user:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -192,10 +221,6 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
return user
|
||||
|
||||
def _validate_session_id(session_id: str) -> None:
|
||||
if not _SESSION_ID_RE.fullmatch(session_id):
|
||||
raise HTTPException(400, "Invalid session ID format")
|
||||
|
||||
def _owns_in_memory(session_id: str, user: str) -> bool:
|
||||
"""Ownership check for an in-flight (in-memory) research task.
|
||||
Falls back to the on-disk JSON if the task has already finished."""
|
||||
@@ -204,15 +229,32 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
return entry.get("owner", "") == user
|
||||
# Task no longer in memory — check the persisted JSON.
|
||||
try:
|
||||
path = _confine_research_path(session_id)
|
||||
return _find_owned_research_path(session_id, user) is not None
|
||||
except HTTPException:
|
||||
return False
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8")).get("owner") == user
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _require_owned_or_active_research_path(session_id: str, user: str) -> Path | None:
|
||||
"""Validate ownership once and return the completed on-disk path.
|
||||
|
||||
Active running research has no completed disk path yet. Completed
|
||||
tasks can remain in _active_tasks after persistence, so prefer their
|
||||
owned disk path when available. Completed disk lookups still reuse the
|
||||
path after the ownership gate.
|
||||
"""
|
||||
entry = research_handler._active_tasks.get(session_id)
|
||||
if entry is not None:
|
||||
if entry.get("owner", "") != user:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
if entry.get("status") != "running":
|
||||
path = _find_owned_research_path(session_id, user)
|
||||
if path is not None:
|
||||
return path
|
||||
return None
|
||||
|
||||
path = _find_owned_research_path(session_id, user)
|
||||
if path is None:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return path
|
||||
|
||||
@router.get("/api/research/active")
|
||||
async def research_active(request: Request):
|
||||
@@ -270,9 +312,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
def _assert_owns_research(session_id: str, user: str) -> None:
|
||||
"""404-not-403 ownership gate for a research session's on-disk JSON.
|
||||
Use BEFORE returning any data or mutating the file."""
|
||||
path = _confine_research_path(session_id)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
path = _require_research_path(session_id)
|
||||
try:
|
||||
owner = json.loads(path.read_text(encoding="utf-8")).get("owner")
|
||||
except Exception:
|
||||
@@ -384,9 +424,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
summary, stats — used by the Library preview panel."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
path = _confine_research_path(session_id)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
path = _require_research_path(session_id)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
@@ -401,9 +439,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
"""Soft-archive / restore a research report (sets `archived` in its JSON)."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
path = _confine_research_path(session_id)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Research not found")
|
||||
path = _require_research_path(session_id)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if data.get("owner") != user:
|
||||
@@ -421,9 +457,9 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
"""Delete a research result from disk."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
json_path = _confine_research_path(session_id)
|
||||
json_path = _find_research_path(session_id)
|
||||
deleted = False
|
||||
if json_path.exists():
|
||||
if json_path is not None:
|
||||
# SECURITY: verify ownership before letting the caller delete it.
|
||||
try:
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
@@ -579,12 +615,11 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
"""Get research result without clearing it (for panel use)."""
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
owned_disk_path = _require_owned_or_active_research_path(session_id, user)
|
||||
result = research_handler.get_result(session_id)
|
||||
if result is None:
|
||||
p = _confine_research_path(session_id)
|
||||
if p.exists():
|
||||
p = owned_disk_path
|
||||
if p is not None:
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"result": d.get("result", ""),
|
||||
@@ -613,8 +648,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
# otherwise any authenticated user could spin off (and thereby read)
|
||||
# another user's report by guessing its session ID. Mirrors every other
|
||||
# endpoint in this file (see result_peek above).
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
owned_disk_path = _require_owned_or_active_research_path(session_id, user)
|
||||
if session_manager is None:
|
||||
raise HTTPException(500, "session_manager not configured")
|
||||
|
||||
@@ -623,8 +657,8 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
sources = research_handler.get_sources(session_id) or []
|
||||
query = ""
|
||||
|
||||
path = _confine_research_path(session_id)
|
||||
if path.exists():
|
||||
path = owned_disk_path
|
||||
if path is not None:
|
||||
try:
|
||||
disk = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not result:
|
||||
|
||||
@@ -207,6 +207,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
"""Setup session routes with the provided manager and config"""
|
||||
|
||||
REQUEST_TIMEOUT = config.get("REQUEST_TIMEOUT", 20)
|
||||
SESSION_MODEL_VALIDATION_TIMEOUT = min(float(REQUEST_TIMEOUT or 20), 3.0)
|
||||
OPENAI_API_KEY = config.get("OPENAI_API_KEY")
|
||||
SESSIONS_FILE = config.get("SESSIONS_FILE")
|
||||
|
||||
@@ -374,7 +375,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
from src.llm_core import list_model_ids
|
||||
ids = list_model_ids(
|
||||
endpoint_url,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
timeout=SESSION_MODEL_VALIDATION_TIMEOUT,
|
||||
headers=validation_headers,
|
||||
owner=user,
|
||||
endpoint_id=endpoint_id.strip() if endpoint_id else None,
|
||||
@@ -394,7 +395,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
req_base = _os.path.basename(model_to_use.rstrip("/"))
|
||||
avail = list_model_ids(
|
||||
endpoint_url,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
timeout=SESSION_MODEL_VALIDATION_TIMEOUT,
|
||||
headers=validation_headers,
|
||||
owner=user,
|
||||
endpoint_id=endpoint_id.strip() if endpoint_id else None,
|
||||
|
||||
+101
-13
@@ -164,6 +164,8 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
|
||||
return bool(binaries.get("llama-server") or dists.get("llama-cpp-python"))
|
||||
if name == "sglang":
|
||||
return bool(dists.get("sglang") or modules.get("sglang", {}).get("real_module"))
|
||||
if name == "mlx_lm":
|
||||
return bool(dists.get("mlx-lm") or modules.get("mlx_lm", {}).get("real_module"))
|
||||
if name == "diffusers":
|
||||
return bool(
|
||||
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
|
||||
@@ -210,6 +212,10 @@ def _package_status_note(name: str, probe: dict) -> str:
|
||||
if _package_installed_from_probe(name, probe):
|
||||
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}"
|
||||
return "Diffusers serving needs both diffusers and torch."
|
||||
if name == "mlx_lm":
|
||||
if _package_installed_from_probe(name, probe):
|
||||
return f"MLX LM {dists.get('mlx-lm', 'available')}"
|
||||
return "MLX serving needs mlx-lm on an Apple Silicon Mac."
|
||||
if name in dists:
|
||||
return f"{name} {dists[name]}"
|
||||
return ""
|
||||
@@ -307,12 +313,14 @@ dist_names={{
|
||||
'vllm':['vllm'],
|
||||
'llama_cpp':['llama-cpp-python'],
|
||||
'sglang':['sglang'],
|
||||
'mlx_lm':['mlx-lm'],
|
||||
'diffusers':['diffusers','torch'],
|
||||
'hf_transfer':['hf-transfer','hf_transfer'],
|
||||
}}
|
||||
bin_names={{
|
||||
'vllm':['vllm'],
|
||||
'llama_cpp':['llama-server'],
|
||||
'tmux':['tmux'],
|
||||
}}
|
||||
|
||||
def add_user_install_bins_to_path():
|
||||
@@ -325,6 +333,8 @@ def add_user_install_bins_to_path():
|
||||
candidates.append(os.path.expanduser('~/llama.cpp/build/bin'))
|
||||
candidates.append(os.path.expanduser('~/llama.cpp/build-vulkan/bin'))
|
||||
candidates.append(os.path.expanduser('~/.local/bin'))
|
||||
candidates.append('/opt/homebrew/bin')
|
||||
candidates.append('/usr/local/bin')
|
||||
parts = os.environ.get('PATH', '').split(os.pathsep) if os.environ.get('PATH') else []
|
||||
changed = False
|
||||
for path in reversed([p for p in candidates if p]):
|
||||
@@ -399,6 +409,47 @@ class ShellExecRequest(BaseModel):
|
||||
use_tmux: bool = False # run in tmux session (survives browser disconnect)
|
||||
|
||||
|
||||
_REMOTE_TMUX_PATH_PREFIX = 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; '
|
||||
|
||||
|
||||
def _normalize_legacy_remote_tmux_exec(command: str) -> str:
|
||||
"""Repair stale frontend Cookbook tmux SSH commands.
|
||||
|
||||
Older loaded JS sends `ssh host 'tmux capture-pane ...'`. On macOS/Homebrew
|
||||
remotes, non-login SSH shells often lack /opt/homebrew/bin, so tmux is
|
||||
installed but the capture/kill command returns nothing. Keep this narrowly
|
||||
scoped to SSH commands whose remote shell starts with `tmux `.
|
||||
"""
|
||||
cmd = command or ""
|
||||
if _REMOTE_TMUX_PATH_PREFIX in cmd or not cmd.lstrip().startswith("ssh "):
|
||||
return cmd
|
||||
try:
|
||||
parts = shlex.split(cmd)
|
||||
except Exception:
|
||||
return cmd
|
||||
if not parts or parts[0] != "ssh":
|
||||
return cmd
|
||||
remote_idx = -1
|
||||
i = 1
|
||||
while i < len(parts):
|
||||
part = parts[i]
|
||||
if part in {"-p", "-o", "-i", "-F", "-J", "-l", "-S", "-W", "-b", "-c", "-m"}:
|
||||
i += 2
|
||||
continue
|
||||
if part.startswith("-"):
|
||||
i += 1
|
||||
continue
|
||||
remote_idx = i
|
||||
break
|
||||
if remote_idx < 0 or remote_idx + 1 >= len(parts):
|
||||
return cmd
|
||||
remote_cmd = " ".join(parts[remote_idx + 1:]).strip()
|
||||
if not remote_cmd.startswith("tmux "):
|
||||
return cmd
|
||||
repaired = parts[:remote_idx + 1] + [_REMOTE_TMUX_PATH_PREFIX + remote_cmd]
|
||||
return shlex.join(repaired)
|
||||
|
||||
|
||||
async def _create_shell(command: str, **kwargs):
|
||||
"""Spawn a shell subprocess for `command`.
|
||||
|
||||
@@ -815,6 +866,10 @@ def setup_shell_routes() -> APIRouter:
|
||||
if not cmd:
|
||||
return {"stdout": "", "stderr": "No command provided", "exit_code": 1}
|
||||
|
||||
fixed_cmd = _normalize_legacy_remote_tmux_exec(cmd)
|
||||
if fixed_cmd != cmd:
|
||||
logger.info("Rewrote legacy remote tmux exec command with Homebrew PATH")
|
||||
cmd = fixed_cmd
|
||||
logger.info("User shell exec requested: length=%d", len(cmd))
|
||||
result = await _exec_shell(
|
||||
cmd, timeout=req.timeout if req.timeout is not None else EXEC_TIMEOUT
|
||||
@@ -1134,6 +1189,13 @@ def setup_shell_routes() -> APIRouter:
|
||||
"category": "LLM",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "mlx_lm",
|
||||
"pip": "mlx-lm",
|
||||
"desc": "Serve MLX-format models on Apple Silicon Macs",
|
||||
"category": "LLM",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "APFEL",
|
||||
"pip": "",
|
||||
@@ -1279,9 +1341,9 @@ def setup_shell_routes() -> APIRouter:
|
||||
for name in all_system_names:
|
||||
qn = shlex.quote(name)
|
||||
checks.append(
|
||||
f"if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi"
|
||||
f"PATH=\"$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"; if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi"
|
||||
)
|
||||
checks.append("echo '---OSREL---'; cat /etc/os-release 2>/dev/null || true")
|
||||
checks.append("echo '---OSREL---'; cat /etc/os-release 2>/dev/null || { [ \"$(uname -s 2>/dev/null)\" = \"Darwin\" ] && echo ID=macos; } || true")
|
||||
inner = " ; ".join(checks)
|
||||
argv = _ssh_base_argv(host, ssh_port) + [inner]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
@@ -1538,6 +1600,7 @@ def setup_shell_routes() -> APIRouter:
|
||||
"onnxruntime",
|
||||
"hdbscan",
|
||||
"vllm",
|
||||
"mlx-lm",
|
||||
}
|
||||
if pip_name not in known:
|
||||
return {"ok": False, "error": f"Unknown package: {pip_name}"}
|
||||
@@ -1584,6 +1647,19 @@ def setup_shell_routes() -> APIRouter:
|
||||
elif n == "g++": out += ["gcc-c++"]
|
||||
else: out.append(n)
|
||||
return out
|
||||
def _apk(names):
|
||||
out = []
|
||||
for n in names:
|
||||
if n == "build-essential": out.append("build-base")
|
||||
else: out.append(n)
|
||||
return out
|
||||
def _zypper(names):
|
||||
out = []
|
||||
for n in names:
|
||||
if n == "build-essential": out += ["gcc-c++", "make"]
|
||||
elif n == "g++": out.append("gcc-c++")
|
||||
else: out.append(n)
|
||||
return out
|
||||
def _brew(names):
|
||||
return [n for n in names if n not in ("build-essential", "g++", "gcc", "make")]
|
||||
# Build a single shell snippet that detects the package manager and
|
||||
@@ -1592,6 +1668,8 @@ def setup_shell_routes() -> APIRouter:
|
||||
apt_pkgs = " ".join(shlex.quote(p) for p in _apt(pkgs))
|
||||
pac_pkgs = " ".join(shlex.quote(p) for p in _pacman(pkgs))
|
||||
dnf_pkgs = " ".join(shlex.quote(p) for p in _dnf(pkgs))
|
||||
apk_pkgs = " ".join(shlex.quote(p) for p in _apk(pkgs))
|
||||
zypper_pkgs = " ".join(shlex.quote(p) for p in _zypper(pkgs))
|
||||
brew_pkgs = " ".join(shlex.quote(p) for p in _brew(pkgs))
|
||||
# Error messages go to stderr (>&2) so the route's error field
|
||||
# gets populated. Without the redirect, `echo "ERROR…"` on stdout
|
||||
@@ -1599,18 +1677,28 @@ def setup_shell_routes() -> APIRouter:
|
||||
# bare "HTTP 200" instead of surfacing the real reason.
|
||||
script = (
|
||||
'set -e; '
|
||||
'if ! sudo -n true 2>/dev/null; then '
|
||||
' echo "ERROR: passwordless sudo unavailable on this target. Run once: sudo apt install -y ' + " ".join(pkgs) + ' (or your distro equivalent: pacman -S, dnf install, brew install). After that, Cookbook can install the rest." >&2; exit 2; fi; '
|
||||
'if command -v apt-get >/dev/null 2>&1; then '
|
||||
f' sudo -n env DEBIAN_FRONTEND=noninteractive apt-get update -qq && sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends {apt_pkgs}; '
|
||||
'elif command -v pacman >/dev/null 2>&1; then '
|
||||
f' sudo -n pacman -Sy --needed --noconfirm {pac_pkgs}; '
|
||||
'elif command -v dnf >/dev/null 2>&1; then '
|
||||
f' sudo -n dnf install -y {dnf_pkgs}; '
|
||||
'elif command -v brew >/dev/null 2>&1; then '
|
||||
f' brew install {brew_pkgs}; '
|
||||
'BREW="$(command -v brew 2>/dev/null || true)"; '
|
||||
'if [ -z "$BREW" ] && [ -x /opt/homebrew/bin/brew ]; then BREW=/opt/homebrew/bin/brew; fi; '
|
||||
'if [ -z "$BREW" ] && [ -x /usr/local/bin/brew ]; then BREW=/usr/local/bin/brew; fi; '
|
||||
'if [ -n "$BREW" ]; then '
|
||||
f' if [ -z "{brew_pkgs}" ]; then echo "Nothing to install with brew for requested packages." >&2; exit 4; fi; "$BREW" install {brew_pkgs}; exit $?; '
|
||||
'fi; '
|
||||
'if [ "$(id -u)" = "0" ]; then SUDO=""; '
|
||||
'elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then SUDO="sudo -n"; '
|
||||
'else '
|
||||
' echo "ERROR: no supported package manager (apt/pacman/dnf/brew) on this target." >&2; exit 3; fi'
|
||||
' echo "ERROR: this target needs sudo for its OS package manager, but passwordless sudo is unavailable. Open a terminal on the target and run the shown install command once, then retry in Cookbook." >&2; exit 2; fi; '
|
||||
'if command -v apt-get >/dev/null 2>&1; then '
|
||||
f' $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update -qq && $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends {apt_pkgs}; '
|
||||
'elif command -v pacman >/dev/null 2>&1; then '
|
||||
f' $SUDO pacman -Sy --needed --noconfirm {pac_pkgs}; '
|
||||
'elif command -v dnf >/dev/null 2>&1; then '
|
||||
f' $SUDO dnf install -y {dnf_pkgs}; '
|
||||
'elif command -v apk >/dev/null 2>&1; then '
|
||||
f' $SUDO apk add --no-interactive {apk_pkgs}; '
|
||||
'elif command -v zypper >/dev/null 2>&1; then '
|
||||
f' $SUDO zypper --non-interactive install {zypper_pkgs}; '
|
||||
'else '
|
||||
' echo "ERROR: no supported package manager (apt/pacman/dnf/apk/zypper/brew) on this target." >&2; exit 3; fi'
|
||||
)
|
||||
try:
|
||||
if host:
|
||||
|
||||
+28
-24
@@ -11,10 +11,14 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.database import SessionLocal, ScheduledTask, TaskRun
|
||||
from core.middleware import INTERNAL_TOOL_USER
|
||||
from core.constants import internal_api_base
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
|
||||
from src.task_action_policy import (
|
||||
ADMIN_ONLY_TASK_ACTIONS,
|
||||
is_admin_only_task_action,
|
||||
owner_has_admin_task_privileges,
|
||||
)
|
||||
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
|
||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||
|
||||
@@ -417,28 +421,18 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
db.close()
|
||||
return {"ok": True, "opened": True, "enabled": bool(prefs.get("tasks_enabled")), "resumed": resumed}
|
||||
|
||||
# Actions that execute shell/SSH commands — restricted to admins.
|
||||
# Actions that execute shell/SSH commands or cross into admin-only
|
||||
# Cookbook serving surfaces — restricted to admins.
|
||||
# Non-admin users cannot create tasks with these action types via the
|
||||
# API. See review CRIT-C.
|
||||
_ADMIN_ONLY_ACTIONS = {"run_local", "run_script", "ssh_command"}
|
||||
_ADMIN_ONLY_ACTIONS = ADMIN_ONLY_TASK_ACTIONS
|
||||
|
||||
def _is_admin(user: str | None) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
# In-process tool-loopback marker — AuthMiddleware validated
|
||||
# the internal token + loopback client before stamping this,
|
||||
# so treat as admin-equivalent.
|
||||
if user == INTERNAL_TOOL_USER:
|
||||
return True
|
||||
try:
|
||||
from core.auth import AuthManager
|
||||
auth = AuthManager()
|
||||
if not auth.is_configured:
|
||||
# Unconfigured single-user deploy: trust the local owner.
|
||||
return True
|
||||
return bool(auth.is_admin(user))
|
||||
except Exception:
|
||||
return False
|
||||
return owner_has_admin_task_privileges(user)
|
||||
|
||||
def _require_admin_for_task_action(user: str | None, task_type: str | None, action: str | None) -> None:
|
||||
if is_admin_only_task_action(task_type, action) and not _is_admin(user):
|
||||
raise HTTPException(403, f"Action '{action}' requires admin privileges")
|
||||
|
||||
def _validate_then_task_id(db, then_task_id: Optional[str], user: Optional[str], current_task_id: Optional[str] = None) -> Optional[str]:
|
||||
target_id = (then_task_id or "").strip()
|
||||
@@ -466,8 +460,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
# Block shell-executing action types for non-admins. action_run_local
|
||||
# uses subprocess.run(shell=True) and ssh_command / run_script run
|
||||
# arbitrary commands.
|
||||
if req.task_type == "action" and req.action in _ADMIN_ONLY_ACTIONS and not _is_admin(user):
|
||||
raise HTTPException(403, f"Action '{req.action}' requires admin privileges")
|
||||
_require_admin_for_task_action(user, req.task_type, req.action)
|
||||
if req.trigger_type == "schedule" and not req.schedule:
|
||||
raise HTTPException(400, "Schedule is required for schedule-triggered tasks")
|
||||
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
|
||||
@@ -681,6 +674,10 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
|
||||
next_task_type = req.task_type if req.task_type is not None else task.task_type
|
||||
next_action = req.action if req.action is not None else task.action
|
||||
_require_admin_for_task_action(user, next_task_type, next_action)
|
||||
|
||||
if req.name is not None:
|
||||
task.name = req.name
|
||||
if req.prompt is not None:
|
||||
@@ -688,9 +685,6 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
if req.task_type is not None:
|
||||
task.task_type = req.task_type
|
||||
if req.action is not None:
|
||||
# Same admin-only gate as create — see CRIT-C.
|
||||
if req.action in _ADMIN_ONLY_ACTIONS and not _is_admin(user):
|
||||
raise HTTPException(403, f"Action '{req.action}' requires admin privileges")
|
||||
task.action = req.action
|
||||
if req.output_target is not None:
|
||||
task.output_target = req.output_target
|
||||
@@ -807,6 +801,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
_require_admin_for_task_action(user, task.task_type, task.action)
|
||||
task.status = "active"
|
||||
if (task.trigger_type or "schedule") == "schedule":
|
||||
task.next_run = compute_next_run(
|
||||
@@ -869,6 +864,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
_require_admin_for_task_action(user, task.task_type, task.action)
|
||||
finally:
|
||||
db.close()
|
||||
started = await task_scheduler.run_task_now(task_id, force=force)
|
||||
@@ -1058,6 +1054,14 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "Not found")
|
||||
if (
|
||||
is_admin_only_task_action(task.task_type, task.action)
|
||||
and not owner_has_admin_task_privileges(task.owner)
|
||||
):
|
||||
task.status = "paused"
|
||||
task.next_run = None
|
||||
db.commit()
|
||||
raise HTTPException(403, f"Action '{task.action}' requires admin privileges")
|
||||
finally:
|
||||
db.close()
|
||||
started = await task_scheduler.run_task_now(task_id)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+117
-19
@@ -168,6 +168,19 @@ def _canonical_cpu_backend(system):
|
||||
return "cpu_x86"
|
||||
|
||||
|
||||
def _is_mlx_model(model, native_q=None):
|
||||
name = (model.get("name") or "").lower()
|
||||
provider = (model.get("provider") or "").lower()
|
||||
fmt = (model.get("format") or "").lower()
|
||||
q = (native_q if native_q is not None else _native_quant(model)).lower()
|
||||
return (
|
||||
q.startswith("mlx-")
|
||||
or provider == "mlx-community"
|
||||
or fmt == "mlx"
|
||||
or name.startswith("mlx-community/")
|
||||
)
|
||||
|
||||
|
||||
def _estimate_speed(model, quant, run_mode, system, offload_frac=0.0):
|
||||
"""Estimate tok/s. Uses active params for MoE (only active experts run per token).
|
||||
|
||||
@@ -313,6 +326,22 @@ def _fit_score(required, available):
|
||||
return 50
|
||||
|
||||
|
||||
def _is_unified_memory_system(system):
|
||||
backend = (system.get("backend") or "").lower()
|
||||
return bool(system.get("unified_memory")) or backend in ("metal", "mps", "apple")
|
||||
|
||||
|
||||
def _fit_level_for_budget(required_gb, budget_gb):
|
||||
if not required_gb or not budget_gb or required_gb > budget_gb:
|
||||
return "too_tight"
|
||||
ratio = required_gb / budget_gb
|
||||
if ratio <= 0.50:
|
||||
return "perfect"
|
||||
if ratio <= 0.78:
|
||||
return "good"
|
||||
return "marginal"
|
||||
|
||||
|
||||
def _context_score(ctx, use_case):
|
||||
target = CONTEXT_TARGET.get(use_case, 4096)
|
||||
if ctx >= target:
|
||||
@@ -516,21 +545,42 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None, targe
|
||||
run_mode, quant, fit_ctx, required_gb = result
|
||||
|
||||
# Determine fit level
|
||||
budget = effective_vram if run_mode == "gpu" else available_ram
|
||||
unified_memory = _is_unified_memory_system(system)
|
||||
total_ram = system.get("total_ram_gb") or available_ram
|
||||
unified_budget = max(total_ram or 0, available_ram or 0, effective_vram or 0)
|
||||
budget = unified_budget if unified_memory else (effective_vram if run_mode == "gpu" else available_ram)
|
||||
if required_gb > budget:
|
||||
return None
|
||||
if run_mode == "gpu":
|
||||
rec = model.get("recommended_ram_gb") or required_gb
|
||||
if rec <= gpu_vram:
|
||||
fit_level = "perfect"
|
||||
elif gpu_vram >= required_gb * 1.2:
|
||||
fit_level = "good"
|
||||
if unified_memory:
|
||||
fit_level = _fit_level_for_budget(required_gb, budget)
|
||||
else:
|
||||
fit_level = "marginal"
|
||||
# GPU-only fit must leave real allocator/KV/runtime headroom. The
|
||||
# old check used recommended_ram_gb (or required_gb as a fallback),
|
||||
# which made any model that barely fit VRAM read as "perfect".
|
||||
# On CUDA/vLLM/SGLang that is misleading: 141 GB on a 160 GB box is
|
||||
# runnable, but not a comfortable perfect fit.
|
||||
if gpu_vram >= required_gb * 1.50:
|
||||
fit_level = "perfect"
|
||||
elif gpu_vram >= required_gb * 1.2:
|
||||
fit_level = "good"
|
||||
else:
|
||||
fit_level = "marginal"
|
||||
elif run_mode == "cpu_offload":
|
||||
fit_level = "good" if available_ram >= required_gb * 1.2 else "marginal"
|
||||
fit_level = _fit_level_for_budget(required_gb, budget)
|
||||
if fit_level == "perfect":
|
||||
fit_level = "good"
|
||||
else:
|
||||
fit_level = "marginal"
|
||||
fit_level = _fit_level_for_budget(required_gb, budget)
|
||||
if fit_level == "too_tight":
|
||||
fit_level = "marginal"
|
||||
|
||||
# Rows that comfortably fit in a huge RAM/unified-memory pool should not all
|
||||
# look "marginal"; that made 1B-70B CPU/Ollama rows orange on 256 GB systems.
|
||||
if fit_level == "marginal" and budget and required_gb <= budget * 0.78:
|
||||
fit_level = "good"
|
||||
if fit_level == "good" and budget and required_gb <= budget * 0.50 and run_mode != "cpu_offload":
|
||||
fit_level = "perfect"
|
||||
|
||||
# Fraction of the model that spills to CPU RAM (drives the offload speed
|
||||
# model). When offloading, anything beyond the GPU's VRAM lives in system RAM.
|
||||
@@ -621,6 +671,40 @@ SORT_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def _search_blob(*parts):
|
||||
text = " ".join(str(p or "") for p in parts).lower()
|
||||
compact = re.sub(r"[^a-z0-9]+", "", text)
|
||||
spaced = re.sub(r"[^a-z0-9]+", " ", text).strip()
|
||||
return f"{text} {spaced} {compact}"
|
||||
|
||||
|
||||
def _matches_search(model, search):
|
||||
terms = [t for t in re.split(r"\s+", (search or "").strip().lower()) if t]
|
||||
if not terms:
|
||||
return True
|
||||
blob = _search_blob(
|
||||
model.get("name"),
|
||||
model.get("provider"),
|
||||
model.get("architecture"),
|
||||
model.get("quantization"),
|
||||
model.get("format"),
|
||||
model.get("parameter_count"),
|
||||
)
|
||||
for term in terms:
|
||||
norm = re.sub(r"[^a-z0-9]+", "", term)
|
||||
if term not in blob and (not norm or norm not in blob):
|
||||
if re.fullmatch(r"\d+(?:\.\d+)?b?", term):
|
||||
try:
|
||||
wanted = float(term.rstrip("b"))
|
||||
actual = params_b(model)
|
||||
except (TypeError, ValueError):
|
||||
actual = 0
|
||||
if wanted > 0 and actual > 0 and abs(actual - wanted) <= max(5.0, wanted * 0.08):
|
||||
continue
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def rank_models(system, use_case=None, limit=50, search=None, sort="score", quant=None, target_context=None, fit_only=False):
|
||||
"""Rank all models against detected hardware. Returns sorted list of fit results.
|
||||
|
||||
@@ -693,10 +777,11 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
|
||||
|
||||
for m in models:
|
||||
native_q = _native_quant(m)
|
||||
is_mlx = _is_mlx_model(m, native_q)
|
||||
|
||||
# MLX needs the mlx_lm runtime, which Odysseus does not generate serve
|
||||
# commands for. Hide it on every backend, including Metal.
|
||||
if native_q.startswith("mlx-") or "mlx" in (m.get("name") or "").lower():
|
||||
# MLX is Apple Silicon-only. It should never appear on CUDA/ROCm/CPU,
|
||||
# but it is first-class on Metal where mlx_lm.server can serve it.
|
||||
if is_mlx and not apple_silicon:
|
||||
continue
|
||||
|
||||
# ROCm support for vLLM/SGLang quantized safetensors is too brittle to
|
||||
@@ -723,7 +808,7 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
|
||||
# Windows is the same: Odysseus only supports llama.cpp on Windows,
|
||||
# which requires GGUF. vLLM/SGLang are explicitly blocked, so AWQ/GPTQ
|
||||
# models without a GGUF source are unservable there.
|
||||
if (apple_silicon or consumer_amd or is_windows) and not (m.get("is_gguf") or m.get("gguf_sources")):
|
||||
if (apple_silicon or consumer_amd or is_windows) and not is_mlx and not (m.get("is_gguf") or m.get("gguf_sources")):
|
||||
continue
|
||||
|
||||
# Format filter: AWQ tab -> only AWQ models, FP4 tab -> FP4-family models, etc.
|
||||
@@ -741,13 +826,26 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
|
||||
if quant in ("INT4", "INT8", "W4A16", "W8A8", "W8A16") and native_q != quant:
|
||||
continue
|
||||
|
||||
if search:
|
||||
name = m.get("name", "").lower()
|
||||
provider = m.get("provider", "").lower()
|
||||
if search.lower() not in name and search.lower() not in provider:
|
||||
continue
|
||||
if search and not _matches_search(m, search):
|
||||
continue
|
||||
|
||||
result = analyze_model(m, system, target_quant=quant, scoring_use_case=(use_case or "general"), target_context=target_context)
|
||||
model_quant = quant
|
||||
# UI "Q4" means the user's looking for a 4-bit fit. On multi-GPU
|
||||
# CUDA/vLLM/SGLang boxes, many practical 4-bit models are native AWQ
|
||||
# safetensors, not GGUF Q4_K_M. If we pass Q4_K_M into a prequantized
|
||||
# AWQ row, analyze_model correctly rejects it as the wrong serving
|
||||
# format, but the result is confusing: highlighting Quant/Q4 hides the
|
||||
# exact AWQ rows the machine is built to run. Treat Q4 as AWQ-4bit for
|
||||
# native AWQ rows only on accelerator servers that can serve them.
|
||||
if (
|
||||
quant == "Q4_K_M"
|
||||
and system.get("gpu_count", 1) >= 2
|
||||
and not (apple_silicon or consumer_amd or is_windows)
|
||||
and native_q == "AWQ-4bit"
|
||||
):
|
||||
model_quant = native_q
|
||||
|
||||
result = analyze_model(m, system, target_quant=model_quant, scoring_use_case=(use_case or "general"), target_context=target_context)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from email.utils import parsedate_to_datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
|
||||
HF_COLLECTIONS_URL = "https://huggingface.co/api/collections"
|
||||
HW_FIT_CACHE_DIR = Path(DATA_DIR) / "hwfit"
|
||||
MLX_COMMUNITY_CACHE = HW_FIT_CACHE_DIR / "mlx_community_models.json"
|
||||
HF_COLLECTION_MODELS_CACHE = HW_FIT_CACHE_DIR / "hf_collection_models.json"
|
||||
HF_COLLECTION_TTL_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
HF_COLLECTION_SOURCES = (
|
||||
{
|
||||
"key": "mlx_community",
|
||||
"owner": "mlx-community",
|
||||
"provider": "mlx-community",
|
||||
"repo_prefix": "mlx-community/",
|
||||
"mlx_only": True,
|
||||
},
|
||||
{
|
||||
"key": "zai_org",
|
||||
"owner": "zai-org",
|
||||
"provider": "zai-org",
|
||||
},
|
||||
{
|
||||
"key": "deepseek_ai",
|
||||
"owner": "deepseek-ai",
|
||||
"provider": "deepseek-ai",
|
||||
},
|
||||
{
|
||||
"key": "minimax_ai",
|
||||
"owner": "MiniMaxAI",
|
||||
"provider": "MiniMaxAI",
|
||||
},
|
||||
{
|
||||
"key": "qwen",
|
||||
"owner": "Qwen",
|
||||
"provider": "Qwen",
|
||||
},
|
||||
{
|
||||
"key": "stepfun_ai",
|
||||
"owner": "stepfun-ai",
|
||||
"provider": "stepfun-ai",
|
||||
},
|
||||
{
|
||||
"key": "google",
|
||||
"owner": "google",
|
||||
"provider": "google",
|
||||
},
|
||||
{
|
||||
"key": "openai",
|
||||
"owner": "openai",
|
||||
"provider": "openai",
|
||||
},
|
||||
{
|
||||
"key": "mistralai",
|
||||
"owner": "mistralai",
|
||||
"provider": "mistralai",
|
||||
},
|
||||
{
|
||||
"key": "meta_llama",
|
||||
"owner": "meta-llama",
|
||||
"provider": "meta-llama",
|
||||
},
|
||||
{
|
||||
"key": "nousresearch",
|
||||
"owner": "NousResearch",
|
||||
"provider": "NousResearch",
|
||||
},
|
||||
{
|
||||
"key": "moonshotai",
|
||||
"owner": "moonshotai",
|
||||
"provider": "moonshotai",
|
||||
},
|
||||
{
|
||||
"key": "mllama",
|
||||
"owner": "mllama",
|
||||
"provider": "mllama",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _format_params(raw):
|
||||
try:
|
||||
n = int(raw or 0)
|
||||
except (TypeError, ValueError):
|
||||
n = 0
|
||||
if n <= 0:
|
||||
return "", 0
|
||||
if n >= 1_000_000_000_000:
|
||||
return f"{n / 1_000_000_000_000:.3g}T", n
|
||||
if n >= 1_000_000_000:
|
||||
return f"{n / 1_000_000_000:.4g}B", n
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1_000_000:.4g}M", n
|
||||
if n >= 1_000:
|
||||
return f"{n / 1_000:.4g}K", n
|
||||
return str(n), n
|
||||
|
||||
|
||||
def _parse_params_from_name(repo_id):
|
||||
name = (repo_id or "").rsplit("/", 1)[-1]
|
||||
active = None
|
||||
m_active = re.search(r"[-_][Aa](\d+(?:\.\d+)?)[Bb](?![a-zA-Z])", name)
|
||||
if m_active:
|
||||
active = int(float(m_active.group(1)) * 1_000_000_000)
|
||||
name = name[: m_active.start()] + name[m_active.end() :]
|
||||
total = None
|
||||
for m in re.finditer(r"(\d+(?:\.\d+)?)[Bb](?![a-zA-Z])", name):
|
||||
total = int(float(m.group(1)) * 1_000_000_000)
|
||||
break
|
||||
if total is None:
|
||||
for m in re.finditer(r"(\d+(?:\.\d+)?)[Mm](?![a-zA-Z])", name):
|
||||
total = int(float(m.group(1)) * 1_000_000)
|
||||
break
|
||||
return total or 0, active
|
||||
|
||||
|
||||
def _infer_quant(repo_id, source):
|
||||
name = (repo_id or "").rsplit("/", 1)[-1].lower()
|
||||
if source.get("mlx_only"):
|
||||
if "8bit" in name or "8-bit" in name:
|
||||
return "mlx-8bit"
|
||||
if "6bit" in name or "6-bit" in name:
|
||||
return "mlx-6bit"
|
||||
if "5bit" in name or "5-bit" in name:
|
||||
return "mlx-5bit"
|
||||
if "3bit" in name or "3-bit" in name:
|
||||
return "mlx-3bit"
|
||||
if re.search(r"(^|[-_/])bf16($|[-_/])", name):
|
||||
return "BF16"
|
||||
return "mlx-4bit"
|
||||
if "awq" in name and ("8bit" in name or "8-bit" in name or "int8" in name):
|
||||
return "AWQ-8bit"
|
||||
if "awq" in name or "4bit" in name or "4-bit" in name:
|
||||
return "AWQ-4bit"
|
||||
if "gptq" in name and ("8bit" in name or "8-bit" in name or "int8" in name):
|
||||
return "GPTQ-Int8"
|
||||
if "gptq" in name:
|
||||
return "GPTQ-Int4"
|
||||
if "mxfp4" in name or "nvfp4" in name or re.search(r"(^|[-_/])fp4($|[-_/])", name):
|
||||
return "FP4-MoE-Mixed"
|
||||
if "mxfp8" in name or re.search(r"(^|[-_/])fp8($|[-_/])", name):
|
||||
return "FP8-Mixed"
|
||||
if "gguf" in name or "q4_k" in name or "q4-k" in name:
|
||||
return "Q4_K_M"
|
||||
if re.search(r"(^|[-_/])bf16($|[-_/])", name):
|
||||
return "BF16"
|
||||
return "BF16"
|
||||
|
||||
|
||||
def _quant_bytes_per_param(quant):
|
||||
return {
|
||||
"BF16": 2.2,
|
||||
"FP8": 1.15,
|
||||
"FP8-Mixed": 1.15,
|
||||
"FP4-MoE-Mixed": 0.62,
|
||||
"AWQ-4bit": 0.62,
|
||||
"AWQ-8bit": 1.15,
|
||||
"GPTQ-Int4": 0.62,
|
||||
"GPTQ-Int8": 1.15,
|
||||
"Q4_K_M": 0.62,
|
||||
"mlx-8bit": 1.25,
|
||||
"mlx-6bit": 0.95,
|
||||
"mlx-5bit": 0.82,
|
||||
"mlx-4bit": 0.70,
|
||||
"mlx-3bit": 0.55,
|
||||
}.get(quant, 2.2)
|
||||
|
||||
|
||||
def _infer_context(repo_id, pipeline_tag):
|
||||
text = f"{repo_id or ''} {pipeline_tag or ''}".lower()
|
||||
if any(k in text for k in ("whisper", "asr", "speech-recognition", "tts", "audio", "image", "video", "diffusion")):
|
||||
return 4096
|
||||
if any(k in text for k in ("glm-5.2", "deepseek-v4", "minimax-m3")):
|
||||
return 1_000_000
|
||||
if any(k in text for k in ("qwen3", "glm", "deepseek", "minimax")):
|
||||
return 32768
|
||||
return 32768
|
||||
|
||||
|
||||
def _infer_use_case(repo_id, pipeline_tag):
|
||||
text = f"{repo_id or ''} {pipeline_tag or ''}".lower()
|
||||
if any(k in text for k in ("whisper", "asr", "speech-recognition", "transcrib")):
|
||||
return "stt"
|
||||
if any(k in text for k in ("tts", "text-to-speech", "kokoro", "audio")):
|
||||
return "tts"
|
||||
if any(k in text for k in ("image-text", "vision", "vlm", "vl-", "ocr", "multimodal")):
|
||||
return "multimodal"
|
||||
if any(k in text for k in ("code", "coder")):
|
||||
return "coding"
|
||||
if any(k in text for k in ("reason", "thinking", "thinker", "r1")):
|
||||
return "reasoning"
|
||||
return "general"
|
||||
|
||||
|
||||
def _entry_from_collection_item(collection, item, source):
|
||||
repo_id = item.get("id") or ""
|
||||
if item.get("type") != "model" or not repo_id:
|
||||
return None
|
||||
repo_prefix = source.get("repo_prefix")
|
||||
if repo_prefix and not repo_id.startswith(repo_prefix):
|
||||
return None
|
||||
raw_params = item.get("numParameters") or 0
|
||||
active = None
|
||||
if not raw_params:
|
||||
raw_params, active = _parse_params_from_name(repo_id)
|
||||
param_label, raw_params = _format_params(raw_params)
|
||||
if not raw_params:
|
||||
return None
|
||||
|
||||
quant = _infer_quant(repo_id, source)
|
||||
pipeline_tag = item.get("pipeline_tag") or ""
|
||||
min_ram = round((raw_params / 1_000_000_000) * _quant_bytes_per_param(quant) + 0.8, 1)
|
||||
last_modified = item.get("lastModified") or collection.get("lastUpdated") or ""
|
||||
release_date = ""
|
||||
if last_modified:
|
||||
try:
|
||||
release_date = parsedate_to_datetime(last_modified).date().isoformat()
|
||||
except Exception:
|
||||
release_date = str(last_modified)[:10]
|
||||
|
||||
entry = {
|
||||
"name": repo_id,
|
||||
"provider": source.get("provider") or repo_id.split("/", 1)[0],
|
||||
"parameter_count": param_label,
|
||||
"parameters_raw": raw_params,
|
||||
"min_ram_gb": min_ram,
|
||||
"recommended_ram_gb": round(min_ram * 1.3 + 0.5, 1),
|
||||
"min_vram_gb": 0.0 if source.get("mlx_only") else min_ram,
|
||||
"quantization": quant,
|
||||
"context_length": _infer_context(repo_id, pipeline_tag),
|
||||
"use_case": _infer_use_case(repo_id, pipeline_tag),
|
||||
"capabilities": ["mlx"] if source.get("mlx_only") else ["vllm", "sglang"],
|
||||
"pipeline_tag": pipeline_tag,
|
||||
"architecture": "",
|
||||
"hf_downloads": int(item.get("downloads") or 0),
|
||||
"hf_likes": int(item.get("likes") or 0),
|
||||
"release_date": release_date,
|
||||
"format": "mlx" if source.get("mlx_only") else "safetensors",
|
||||
"collection": collection.get("title") or "",
|
||||
"description": collection.get("description") or "",
|
||||
"_discovered": True,
|
||||
"_source": "hf_collections",
|
||||
"_source_owner": source.get("owner") or "",
|
||||
}
|
||||
if source.get("mlx_only"):
|
||||
entry["mlx_only"] = True
|
||||
if quant == "Q4_K_M":
|
||||
entry["is_gguf"] = True
|
||||
entry["format"] = "gguf"
|
||||
entry["capabilities"] = ["llama.cpp"]
|
||||
if active:
|
||||
entry["is_moe"] = True
|
||||
entry["active_parameters"] = active
|
||||
return entry
|
||||
|
||||
|
||||
def _next_link(header):
|
||||
if not header:
|
||||
return None
|
||||
m = re.search(r'<([^>]+)>;\s*rel="next"', header)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def fetch_collection_models(source, timeout=20, max_pages=20):
|
||||
params = urllib.parse.urlencode({
|
||||
"owner": source["owner"],
|
||||
"limit": "100",
|
||||
"expand": "true",
|
||||
})
|
||||
url = f"{HF_COLLECTIONS_URL}?{params}"
|
||||
models = {}
|
||||
pages = 0
|
||||
while url and pages < max_pages:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "odysseus-hwfit/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
payload = json.load(resp)
|
||||
url = _next_link(resp.headers.get("Link"))
|
||||
pages += 1
|
||||
if not isinstance(payload, list):
|
||||
break
|
||||
for collection in payload:
|
||||
if not isinstance(collection, dict):
|
||||
continue
|
||||
for item in collection.get("items") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
entry = _entry_from_collection_item(collection, item, source)
|
||||
if entry and entry["name"] not in models:
|
||||
models[entry["name"]] = entry
|
||||
rows = list(models.values())
|
||||
rows.sort(key=lambda x: (x.get("hf_downloads") or 0, x.get("release_date") or ""), reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def _load_cache(path):
|
||||
try:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
rows = data.get("models") if isinstance(data, dict) else data
|
||||
return rows if isinstance(rows, list) else []
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _write_cache(path, source, rows):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"source": source,
|
||||
"fetched_at": int(time.time()),
|
||||
"count": len(rows),
|
||||
"models": rows,
|
||||
}
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def load_cached_mlx_community_models():
|
||||
return _load_cache(MLX_COMMUNITY_CACHE)
|
||||
|
||||
|
||||
def load_cached_hf_collection_models():
|
||||
return _load_cache(HF_COLLECTION_MODELS_CACHE)
|
||||
|
||||
|
||||
def _cache_fresh(path):
|
||||
try:
|
||||
return (time.time() - path.stat().st_mtime) < HF_COLLECTION_TTL_SECONDS
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def refresh_mlx_community_cache(force=False):
|
||||
if not force and _cache_fresh(MLX_COMMUNITY_CACHE):
|
||||
return load_cached_mlx_community_models()
|
||||
source = next(s for s in HF_COLLECTION_SOURCES if s["key"] == "mlx_community")
|
||||
rows = fetch_collection_models(source)
|
||||
_write_cache(MLX_COMMUNITY_CACHE, "https://huggingface.co/mlx-community/collections", rows)
|
||||
return rows
|
||||
|
||||
|
||||
def refresh_hf_collection_models_cache(force=False):
|
||||
if not force and _cache_fresh(HF_COLLECTION_MODELS_CACHE):
|
||||
return load_cached_hf_collection_models()
|
||||
rows_by_name = {}
|
||||
for source in HF_COLLECTION_SOURCES:
|
||||
if source["key"] == "mlx_community":
|
||||
continue
|
||||
try:
|
||||
for row in fetch_collection_models(source):
|
||||
rows_by_name.setdefault(row["name"], row)
|
||||
except Exception:
|
||||
# Keep partial refreshes useful. A temporary DNS/provider issue for
|
||||
# one brand should not invalidate the other cached collection rows.
|
||||
continue
|
||||
rows = sorted(
|
||||
rows_by_name.values(),
|
||||
key=lambda x: (x.get("hf_downloads") or 0, x.get("release_date") or ""),
|
||||
reverse=True,
|
||||
)
|
||||
if rows:
|
||||
_write_cache(HF_COLLECTION_MODELS_CACHE, "https://huggingface.co/collections", rows)
|
||||
return rows
|
||||
return load_cached_hf_collection_models()
|
||||
+77
-10
@@ -13,7 +13,7 @@ QUANT_BPP = {
|
||||
"AWQ-4bit": 0.50, "AWQ-8bit": 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-3bit": 0.42, "mlx-4bit": 0.55, "mlx-5bit": 0.65, "mlx-6bit": 0.75, "mlx-8bit": 1.0,
|
||||
# DeepSeek-V4-style mixed: MoE experts in FP4 (bulk), attention + non-
|
||||
# expert dense in FP8, embeddings/LM head in BF16. By weight count the
|
||||
# experts dominate so the effective BPP sits closer to FP4 than FP8.
|
||||
@@ -32,7 +32,7 @@ QUANT_SPEED_MULT = {
|
||||
"AWQ-4bit": 1.2, "AWQ-8bit": 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-3bit": 1.25, "mlx-4bit": 1.15, "mlx-5bit": 1.05, "mlx-6bit": 1.0, "mlx-8bit": 0.85,
|
||||
"FP4-MoE-Mixed": 1.10, # slightly slower than pure FP4 because of mixed-dtype dispatch
|
||||
"FP8-Mixed": 0.85,
|
||||
}
|
||||
@@ -53,7 +53,7 @@ QUANT_QUALITY_PENALTY = {
|
||||
# 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-3bit": -8.0, "mlx-4bit": -4.0, "mlx-5bit": -2.5, "mlx-6bit": -1.5, "mlx-8bit": -0.5,
|
||||
# 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 —
|
||||
# the activation-sensitive layers stay high-precision. ~0 penalty.
|
||||
@@ -70,7 +70,7 @@ QUANT_BYTES_PER_PARAM = {
|
||||
"AWQ-4bit": 0.5, "AWQ-8bit": 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-3bit": 0.375, "mlx-4bit": 0.5, "mlx-5bit": 0.625, "mlx-6bit": 0.75, "mlx-8bit": 1.0,
|
||||
"FP4-MoE-Mixed": 0.55,
|
||||
"FP8-Mixed": 1.0,
|
||||
}
|
||||
@@ -87,8 +87,11 @@ PREQUANTIZED_PREFIXES = (
|
||||
|
||||
def infer_quantization_from_name(name):
|
||||
n = (name or "").lower()
|
||||
model_name = n.rsplit("/", 1)[-1]
|
||||
if "nvfp4" in n:
|
||||
return "NVFP4"
|
||||
if re.search(r"(^|[-_/])bf16($|[-_/])", model_name):
|
||||
return "BF16"
|
||||
if "mxfp4" in n:
|
||||
return "MXFP4"
|
||||
if re.search(r"(^|[-_/])nf4($|[-_/])", n):
|
||||
@@ -106,8 +109,12 @@ def infer_quantization_from_name(name):
|
||||
return "AWQ-8bit" if is8 else "AWQ-4bit"
|
||||
if "gptq" in n:
|
||||
return "GPTQ-Int8" if is8 else "GPTQ-Int4"
|
||||
if "mlx" in n:
|
||||
if "6bit" in n:
|
||||
if n.startswith("mlx-community/") or "mlx" in model_name:
|
||||
if "3bit" in model_name:
|
||||
return "mlx-3bit"
|
||||
if "5bit" in model_name:
|
||||
return "mlx-5bit"
|
||||
if "6bit" in model_name:
|
||||
return "mlx-6bit"
|
||||
return "mlx-8bit" if is8 else "mlx-4bit"
|
||||
if "fp8" in n:
|
||||
@@ -260,15 +267,75 @@ def infer_use_case(model):
|
||||
|
||||
_models_cache = None
|
||||
|
||||
def _load_model_file(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
return loaded if isinstance(loaded, list) else []
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
def reset_model_cache():
|
||||
global _models_cache
|
||||
_models_cache = None
|
||||
|
||||
def refresh_dynamic_catalogs(force=False):
|
||||
"""Refresh API-backed model catalogs and invalidate the merged cache.
|
||||
|
||||
The bundled JSON files remain the offline fallback. Dynamic catalogs live
|
||||
under DATA_DIR so runtime refreshes do not dirty the source tree.
|
||||
"""
|
||||
from services.hwfit.hf_discovery import (
|
||||
refresh_hf_collection_models_cache,
|
||||
refresh_mlx_community_cache,
|
||||
)
|
||||
|
||||
refreshed = {
|
||||
"mlx_community": len(refresh_mlx_community_cache(force=force)),
|
||||
"hf_collections": len(refresh_hf_collection_models_cache(force=force)),
|
||||
}
|
||||
reset_model_cache()
|
||||
return refreshed
|
||||
|
||||
def get_models():
|
||||
global _models_cache
|
||||
if _models_cache is None:
|
||||
data_path = os.path.join(os.path.dirname(__file__), "data", "hf_models.json")
|
||||
static_mlx_path = os.path.join(os.path.dirname(__file__), "data", "mlx_community_models.json")
|
||||
try:
|
||||
with open(data_path, encoding="utf-8") as f:
|
||||
_models_cache = [_normalize_model_entry(m) for m in json.load(f)]
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
_models_cache = []
|
||||
from services.hwfit.hf_discovery import (
|
||||
load_cached_hf_collection_models,
|
||||
load_cached_mlx_community_models,
|
||||
)
|
||||
dynamic_mlx_models = load_cached_mlx_community_models()
|
||||
dynamic_hf_models = load_cached_hf_collection_models()
|
||||
except Exception:
|
||||
dynamic_mlx_models = []
|
||||
dynamic_hf_models = []
|
||||
seen = set()
|
||||
rows = []
|
||||
def _append_models(models):
|
||||
for model in models:
|
||||
if not isinstance(model, dict):
|
||||
continue
|
||||
name = model.get("name")
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
rows.append(_normalize_model_entry(model))
|
||||
|
||||
for model in _load_model_file(data_path):
|
||||
if not isinstance(model, dict):
|
||||
continue
|
||||
name = model.get("name")
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
rows.append(_normalize_model_entry(model))
|
||||
_append_models(dynamic_hf_models)
|
||||
_append_models(dynamic_mlx_models)
|
||||
_append_models(_load_model_file(static_mlx_path))
|
||||
_models_cache = rows
|
||||
return _models_cache
|
||||
|
||||
|
||||
|
||||
@@ -34,8 +34,14 @@ def _extract_entities(query: str) -> Dict[str, List[str]]:
|
||||
cleaned = query
|
||||
if qtype:
|
||||
cleaned = re.sub(rf"^{qtype}\b", "", cleaned, flags=re.I).strip()
|
||||
for token in re.findall(r"\b[A-Z][a-zA-Z]+\b", cleaned):
|
||||
entities["names"].append(token)
|
||||
# Unicode-aware capitalized-word (name) detection. The old [A-Z][a-zA-Z]+
|
||||
# class missed non-ASCII names like "İstanbul"/"Zürich" (dropped) and
|
||||
# "São" (shredded). Keep the ASCII behaviour — the word boundary already
|
||||
# excludes camelCase mid-word capitals — by requiring an all-alphabetic
|
||||
# token of length > 1 whose first character is uppercase.
|
||||
for token in re.findall(r"\b\w+\b", cleaned):
|
||||
if len(token) > 1 and token[0].isupper() and token.isalpha():
|
||||
entities["names"].append(token)
|
||||
for year in re.findall(r"\b(?:19|20)\d{2}\b", cleaned):
|
||||
entities["dates"].append(year)
|
||||
month_day_year = re.findall(
|
||||
|
||||
+15
-2
@@ -92,8 +92,21 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
||||
|
||||
# Deep research jobs, not quick conceptual mentions of research.
|
||||
("web", "explicit web search request", rf"{_PLEASE}(?:do|run|use|perform|make)\s+(?:a\s+)?(?:web\s+search|search\s+the\s+web)\b.+"),
|
||||
("web", "web lookup imperative request", rf"{_PLEASE}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
|
||||
("web", "assistant web lookup request", rf"{_ACTION_QUESTION}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
|
||||
("web", "generic search request", rf"{_PLEASE}search\s+(?!(?:my\s+)?(?:chats?|history|sessions?|notes?|todos?|emails?|mail|inbox|documents?|docs|gallery|images?|files?)\b).+"),
|
||||
("web", "web lookup imperative request", rf"{_PLEASE}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google(?:\s+it)?)\b.*"),
|
||||
("web", "short web lookup follow-up", rf"{_PLEASE}(?:just\s+)?(?:look\s+it\s+up|look\s+up|search\s+(?:online|web|now)|search\s+it)\b\s*$"),
|
||||
("web", "assistant short web lookup request", rf"{_ACTION_QUESTION}(?:search|look\s+up|google)(?:\s+(?:online|web|now|it))?\b.*"),
|
||||
("web", "assistant web lookup request", rf"{_ACTION_QUESTION}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google(?:\s+it)?)\b.*"),
|
||||
("web", "assistant weather check request", rf"{_ACTION_QUESTION}(?:check|find|get|look\s+up)\b.{{0,100}}\b(?:weather|forecast)\b.*"),
|
||||
("web", "news lookup request", r"\b(?:news|headlines)\s+(?:in|from|about|for)\s+[\w\s.-]{2,80}\??\s*$"),
|
||||
("web", "forecast lookup request", r"\b(?:hourly|daily|weekly|local)\s+(?:weather\s+)?forecast\b|\b(?:weather\s+)?forecast\s+(?:for|today|tomorrow|now|hourly)\b"),
|
||||
("web", "weather lookup request", r"\bweather\b.{0,80}\b(?:hourly|rain|raining|rin|today|tomorrow|update|current|now)\b|\b(?:hourly|rain|raining|rin)\b.{0,80}\bweather\b"),
|
||||
("web", "rain lookup request", r"\b(?:hourly|daily|weekly|local|today|tomorrow|current|now|update)\b.{0,100}\b(?:rain|raining|rainy|precipitation|showers?)\b|\b(?:rain|raining|rainy|precipitation|showers?)\b.{0,100}\b(?:hourly|daily|weekly|local|today|tomorrow|current|now|update|in|for|at)\b"),
|
||||
("web", "bare weather lookup request", r"\b(?:weather|forecast)\s+(?:in|for|at)?\s*[\w\s.-]{2,80}\??\s*$|\b[\w\s.-]{2,80}\s+(?:weather|forecast)\??\s*$"),
|
||||
("web", "latest info lookup request", r"\b(?:latest|current|newest|recent|up(?: |-)?to(?: |-)?date)\s+(?:info|information|updates?|details?|developments?)\s+(?:on|about|for|in)\s+[\w\s.,:'\"/-]{2,120}\??\s*$"),
|
||||
("web", "current/latest lookup request", r"\b(?:current|latest|today'?s?|right\s+now|live|online)\b.{0,120}\b(?:rate|price|news|weather|forecast|score|exchange|market|status)\b"),
|
||||
("web", "rate/price/news lookup request", r"\b(?:rate|rates|price|prices|news|weather|forecast|score|exchange|currency|market)\b.{0,120}\b(?:now|today|current|latest|online|live|search|look\s+up|find)\b"),
|
||||
("web", "conversion-rate lookup request", r"\b(?:convert|conversion|exchange)\b.{0,120}\b(?:rate|rates|currency|currencies|price|prices)\b"),
|
||||
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
|
||||
|
||||
+517
-255
File diff suppressed because it is too large
Load Diff
@@ -130,15 +130,70 @@ def _looks_like_email_document(text: str = "", title: str = "") -> bool:
|
||||
return True
|
||||
return bool(_re.search(r"(?im)^To:\s*", s) and _re.search(r"(?im)^Subject:\s*", s))
|
||||
|
||||
def _split_email_header_body(text: str) -> tuple[str, str]:
|
||||
if "\n---\n" in (text or ""):
|
||||
header, body = (text or "").split("\n---\n", 1)
|
||||
return header.rstrip(), body.strip()
|
||||
return (text or "").strip(), ""
|
||||
|
||||
def _split_email_reply_history(body: str) -> tuple[str, str]:
|
||||
"""Split draft body from quoted/original email history.
|
||||
|
||||
Email reply docs keep the original thread below the user's new reply. Models
|
||||
often rewrite only the fresh reply body; this helper keeps the historical
|
||||
block from being wiped when update_document/edit_document replaces content.
|
||||
"""
|
||||
text = body or ""
|
||||
literal = "---------- Previous message ----------"
|
||||
literal_idx = text.find(literal)
|
||||
if literal_idx >= 0:
|
||||
return text[:literal_idx].strip(), text[literal_idx:].strip()
|
||||
patterns = [
|
||||
r"(?m)^On .+ wrote:\s*$",
|
||||
r"(?m)^> .+",
|
||||
]
|
||||
starts = []
|
||||
for pat in patterns:
|
||||
m = re.search(pat, text)
|
||||
if m:
|
||||
starts.append(m.start())
|
||||
if not starts:
|
||||
return text.strip(), ""
|
||||
idx = min(starts)
|
||||
return text[:idx].strip(), text[idx:].strip()
|
||||
|
||||
def _merge_email_headers(old_header: str, new_header: str) -> str:
|
||||
"""Preserve routing/threading metadata if a model omits it."""
|
||||
protected = (
|
||||
"In-Reply-To", "References", "X-Source-UID", "X-Source-Folder",
|
||||
"X-Attachments", "X-Forward-Attachments",
|
||||
)
|
||||
lines = [l for l in (new_header or "").splitlines() if l.strip()]
|
||||
present = {l.split(":", 1)[0].strip().lower() for l in lines if ":" in l}
|
||||
for old_line in (old_header or "").splitlines():
|
||||
if ":" not in old_line:
|
||||
continue
|
||||
key = old_line.split(":", 1)[0].strip()
|
||||
if key in protected and key.lower() not in present:
|
||||
lines.append(old_line)
|
||||
present.add(key.lower())
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
def _coerce_email_document_content(existing: str, incoming: str) -> str:
|
||||
"""Keep email docs in the To/Subject/---/body shape even if a model writes
|
||||
only the body or dumps header labels without the separator."""
|
||||
import re as _re
|
||||
old = existing or ""
|
||||
new = (incoming or "").strip()
|
||||
old_header, old_body = _split_email_header_body(old)
|
||||
_, old_history = _split_email_reply_history(old_body)
|
||||
if "\n---\n" in new:
|
||||
return new
|
||||
header = old.split("\n---\n", 1)[0] if "\n---\n" in old else "To: \nSubject: "
|
||||
new_header, new_body = _split_email_header_body(new)
|
||||
new_own, new_history = _split_email_reply_history(new_body)
|
||||
if old_history and not new_history:
|
||||
new_body = (new_own + "\n\n" + old_history).strip()
|
||||
return _merge_email_headers(old_header, new_header).rstrip() + "\n---\n" + new_body
|
||||
header = old_header if old_header else "To: \nSubject: "
|
||||
if _looks_like_email_document(new):
|
||||
lines = new.splitlines()
|
||||
last_header_idx = -1
|
||||
@@ -152,6 +207,9 @@ def _coerce_email_document_content(existing: str, incoming: str) -> str:
|
||||
body = "\n".join(body_lines).strip()
|
||||
else:
|
||||
body = new
|
||||
_, incoming_history = _split_email_reply_history(body)
|
||||
if old_history and not incoming_history:
|
||||
body = (body.strip() + "\n\n" + old_history).strip()
|
||||
return header.rstrip() + "\n---\n" + body
|
||||
|
||||
def parse_edit_blocks(content: str) -> list:
|
||||
@@ -463,6 +521,42 @@ class EditDocumentTool:
|
||||
if not doc:
|
||||
return {"error": "No documents exist to edit"}
|
||||
|
||||
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
|
||||
blank_find_edits = [e for e in edits if not (e.get("find") or "").strip()]
|
||||
if blank_find_edits:
|
||||
if is_email_doc:
|
||||
replacement_body = (blank_find_edits[0].get("replace") or "").strip()
|
||||
if not replacement_body:
|
||||
return {"error": "No edits applied — blank FIND block had no replacement text"}
|
||||
updated_content = _coerce_email_document_content(doc.current_content or "", replacement_body)
|
||||
applied = 1
|
||||
skipped = max(0, len(edits) - 1)
|
||||
doc.language = "email"
|
||||
new_ver = doc.version_count + 1
|
||||
ver = DocumentVersion(
|
||||
id=str(uuid.uuid4()),
|
||||
document_id=target_id,
|
||||
version_number=new_ver,
|
||||
content=updated_content,
|
||||
summary=f"Edited email body by {_active_model or 'AI'}",
|
||||
source="ai",
|
||||
)
|
||||
doc.current_content = updated_content
|
||||
doc.version_count = new_ver
|
||||
db.add(ver)
|
||||
db.commit()
|
||||
return {
|
||||
"action": "edit",
|
||||
"doc_id": target_id,
|
||||
"title": doc.title,
|
||||
"language": doc.language,
|
||||
"content": updated_content,
|
||||
"version": new_ver,
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
}
|
||||
return {"error": "No edits applied — FIND text cannot be blank"}
|
||||
|
||||
updated_content = doc.current_content
|
||||
applied = 0
|
||||
skipped = 0
|
||||
|
||||
@@ -424,8 +424,12 @@ class GrepTool:
|
||||
cmd.append("--ignore-case")
|
||||
if glob_pat:
|
||||
cmd += ["--glob", glob_pat]
|
||||
# --iglob (not --glob) so the exclusion is case-insensitive:
|
||||
# on a case-insensitive filesystem "ID_RSA"/"Known_Hosts"
|
||||
# resolve to the same secret as their lowercase forms, and the
|
||||
# Python fallback below already folds case via _is_sensitive_path.
|
||||
for _pat in _SENSITIVE_FILE_PATTERNS:
|
||||
cmd += ["--glob", f"!*{_pat}*"]
|
||||
cmd += ["--iglob", f"!*{_pat}*"]
|
||||
for _d in _CODENAV_SKIP_DIRS:
|
||||
cmd += ["--glob", f"!**/{_d}/**"]
|
||||
cmd += ["--regexp", pattern, root]
|
||||
|
||||
@@ -184,8 +184,12 @@ async def send_to_session(content: str, session_id: Optional[str] = None, owner:
|
||||
if not sess:
|
||||
return {"error": f"Session '{target_sid}' not found"}
|
||||
|
||||
# Owner-scope: reject access to another user's session
|
||||
if owner and getattr(sess, "owner", None) and sess.owner != owner:
|
||||
# Owner-scope: reject access to another user's session. When the caller is
|
||||
# authenticated, a null-owner (legacy / auth-was-off) session is not theirs
|
||||
# either — list_sessions (get_sessions_for_user) and manage_session already
|
||||
# exclude those, so treating it as reachable here let an authenticated agent
|
||||
# read/write a session the other tools hide. Require an exact owner match.
|
||||
if owner and getattr(sess, "owner", None) != owner:
|
||||
return {"error": f"Session '{target_sid}' not found"}
|
||||
|
||||
if not message:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
@@ -109,8 +110,20 @@ class WebFetchTool:
|
||||
url = "https://" + url
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
def _fetch():
|
||||
kwargs = {"timeout": 10}
|
||||
try:
|
||||
sig = inspect.signature(fetch_webpage_content)
|
||||
if "max_bytes" in sig.parameters:
|
||||
kwargs["max_bytes"] = max_bytes
|
||||
except (TypeError, ValueError):
|
||||
# Some deployed/test shims may not expose a signature.
|
||||
# Prefer compatibility over failing the whole fetch.
|
||||
pass
|
||||
return fetch_webpage_content(url, **kwargs)
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, lambda: fetch_webpage_content(url, timeout=10, max_bytes=max_bytes)),
|
||||
loop.run_in_executor(None, _fetch),
|
||||
timeout=30,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
+4
-1
@@ -230,7 +230,10 @@ def request_flags(messages) -> tuple:
|
||||
"""
|
||||
msgs = messages or []
|
||||
last = msgs[-1] if msgs else None
|
||||
agent = bool(last) and last.get("role") != "user"
|
||||
# A message element can be a non-dict (clients send `"messages": ["hi"]`);
|
||||
# the vision loop below already guards each element with isinstance, so do
|
||||
# the same here rather than call .get() on a bare string.
|
||||
agent = isinstance(last, dict) and last.get("role") != "user"
|
||||
vision = False
|
||||
for m in msgs:
|
||||
content = m.get("content") if isinstance(m, dict) else None
|
||||
|
||||
@@ -440,7 +440,10 @@ def build_user_content(
|
||||
try:
|
||||
with open(path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
|
||||
image_format = ext[1:]
|
||||
# Extensionless uploads (e.g. a pasted screenshot) have no ext,
|
||||
# so fall back to the resolved MIME subtype rather than emitting
|
||||
# an invalid "data:image/;base64," with an empty subtype.
|
||||
image_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/{image_format};base64,{encoded_string}"},
|
||||
@@ -456,7 +459,7 @@ def build_user_content(
|
||||
try:
|
||||
with open(path, "rb") as audio_file:
|
||||
encoded_string = base64.b64encode(audio_file.read()).decode("utf-8")
|
||||
audio_format = ext[1:]
|
||||
audio_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("audio/") else "mpeg")
|
||||
content.append({
|
||||
"type": "audio",
|
||||
"audio": {"url": f"data:audio/{audio_format};base64,{encoded_string}"},
|
||||
|
||||
@@ -47,6 +47,33 @@ def _endpoint_cached_models(ep) -> list:
|
||||
return models if isinstance(models, list) else []
|
||||
|
||||
|
||||
def _endpoint_pinned_models(ep) -> list:
|
||||
raw = getattr(ep, "pinned_models", None)
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
models = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
return []
|
||||
return models if isinstance(models, list) else []
|
||||
|
||||
|
||||
def _is_mlx_deepseek_v4_repo_id(model_id: str) -> bool:
|
||||
return "mlx-community/deepseek-v4" in str(model_id or "").lower()
|
||||
|
||||
|
||||
def _is_mlx_deepseek_v4_shim_id(model_id: str) -> bool:
|
||||
return "/.cache/odysseus/mlx-shims/deepseek-v4" in str(model_id or "").lower()
|
||||
|
||||
|
||||
def _filter_mlx_deepseek_v4_repo_when_shimmed(model_ids) -> list:
|
||||
ids = list(model_ids or [])
|
||||
has_shim = any(_is_mlx_deepseek_v4_shim_id(m) for m in ids)
|
||||
if not has_shim:
|
||||
return ids
|
||||
return [m for m in ids if not _is_mlx_deepseek_v4_repo_id(m)]
|
||||
|
||||
|
||||
def _endpoint_hidden_models(ep) -> set:
|
||||
"""Model ids the admin disabled on this endpoint (the UI's hidden list)."""
|
||||
raw = getattr(ep, "hidden_models", None)
|
||||
@@ -67,7 +94,15 @@ def _endpoint_enabled_models(ep) -> list:
|
||||
raw first one resolves to a model that 400s ("requires terms acceptance").
|
||||
"""
|
||||
hidden = _endpoint_hidden_models(ep)
|
||||
return [m for m in _endpoint_cached_models(ep) if m not in hidden]
|
||||
merged = []
|
||||
seen = set()
|
||||
for m in [*_endpoint_cached_models(ep), *_endpoint_pinned_models(ep)]:
|
||||
if not isinstance(m, str) or not m or m in seen:
|
||||
continue
|
||||
seen.add(m)
|
||||
merged.append(m)
|
||||
merged = _filter_mlx_deepseek_v4_repo_when_shimmed(merged)
|
||||
return [m for m in merged if m not in hidden]
|
||||
|
||||
|
||||
def resolve_endpoint_runtime(ep, owner: Optional[str] = None) -> Tuple[str, Optional[str]]:
|
||||
|
||||
+24
-1
@@ -216,7 +216,14 @@ def _normalize_integration_base_url(base_url: Any) -> str:
|
||||
|
||||
|
||||
def _join_integration_url(base_url: str, path: str) -> str:
|
||||
return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
|
||||
base = base_url.rstrip("/")
|
||||
rel = path.lstrip("/")
|
||||
if not rel:
|
||||
# A bare "/" must resolve to the base URL itself, not base + "/".
|
||||
# POST-to-base integrations (e.g. Discord webhooks) 404 on the
|
||||
# trailing-slash variant of their URL.
|
||||
return base
|
||||
return urljoin(base + "/", rel)
|
||||
|
||||
|
||||
def load_integrations() -> List[Dict[str, Any]]:
|
||||
@@ -394,6 +401,22 @@ async def execute_api_call(
|
||||
return {"error": "Path must not contain a fragment", "exit_code": 1}
|
||||
|
||||
url = _join_integration_url(base_url, path)
|
||||
|
||||
# SSRF guard — same check used by the gallery endpoint, embeddings,
|
||||
# CardDAV, and the reminder webhook sender. Link-local / metadata
|
||||
# addresses (169.254.x.x — the cloud credential-exfil vector) are always
|
||||
# rejected; INTEGRATION_API_BLOCK_PRIVATE_IPS=true also blocks RFC-1918 /
|
||||
# loopback for locked-down deployments. Private stays allowed by default
|
||||
# because LAN integrations (Home Assistant, Miniflux, ntfy) are the
|
||||
# primary use case.
|
||||
from src.url_safety import check_outbound_url
|
||||
block_private = os.getenv(
|
||||
"INTEGRATION_API_BLOCK_PRIVATE_IPS", "false"
|
||||
).lower() == "true"
|
||||
ok, reason = check_outbound_url(url, block_private=block_private)
|
||||
if not ok:
|
||||
return {"error": f"URL rejected: {reason}", "exit_code": 1}
|
||||
|
||||
method = method.upper()
|
||||
|
||||
# Build headers
|
||||
|
||||
+17
-7
@@ -17,6 +17,7 @@ _ACTIVE_REQUESTS = 0
|
||||
_LAST_ACTIVITY = 0.0
|
||||
_LAST_BROWSER_ACTIVITY = 0.0
|
||||
_COND: asyncio.Condition | None = None
|
||||
_COND_LOOP: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
@@ -47,14 +48,20 @@ def _browser_active_seconds() -> float:
|
||||
|
||||
|
||||
def _condition() -> asyncio.Condition:
|
||||
global _COND
|
||||
if _COND is None:
|
||||
global _COND, _COND_LOOP
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
if _COND is None or _COND_LOOP is not loop:
|
||||
_COND = asyncio.Condition()
|
||||
_COND_LOOP = loop
|
||||
return _COND
|
||||
|
||||
|
||||
_PASSIVE_EXACT_PATHS = {
|
||||
"/api/activity/heartbeat",
|
||||
"/api/client-perf",
|
||||
"/api/tasks/notifications",
|
||||
"/api/research/active",
|
||||
"/api/email/urgency-state",
|
||||
@@ -100,15 +107,18 @@ def _has_recent_browser_activity(now: float | None = None) -> bool:
|
||||
def has_foreground_activity(now: float | None = None) -> bool:
|
||||
"""Return True when foreground browser/model work should stop background jobs.
|
||||
|
||||
This is intentionally narrower than `wait_for_interactive_quiet`: active
|
||||
request tracking is good for delaying task startup, but a running task
|
||||
should not cancel itself just because the UI polls a passive endpoint.
|
||||
Browser heartbeats and active chat streams are the durable "user is here"
|
||||
signals.
|
||||
Passive polling endpoints are excluded by should_track_interactive_request,
|
||||
so active/recent request tracking is safe to use here. This matters during
|
||||
initial page load: the heartbeat may not have landed yet, but the user is
|
||||
already waiting on real UI requests.
|
||||
"""
|
||||
if not _enabled():
|
||||
return False
|
||||
t = now if now is not None else time.monotonic()
|
||||
if _ACTIVE_REQUESTS > 0:
|
||||
return True
|
||||
if _LAST_ACTIVITY > 0 and (t - _LAST_ACTIVITY) < _quiet_seconds():
|
||||
return True
|
||||
return _has_recent_browser_activity(t) or _has_active_chat_stream()
|
||||
|
||||
|
||||
|
||||
+248
-5
@@ -8,13 +8,88 @@ import hashlib
|
||||
import threading
|
||||
import re
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import HTTPException
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from src.model_context import get_context_length, DEFAULT_CONTEXT
|
||||
from src.model_context import get_context_length, DEFAULT_CONTEXT, is_local_endpoint
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCAL_MODEL_LOCK = asyncio.Lock()
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND = 0
|
||||
_LOCAL_MODEL_CURRENT: Dict[str, object] = {}
|
||||
|
||||
|
||||
def _local_model_gate_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_LOCAL_MODEL_GATE", "true").lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _gate_workload(workload: Optional[str]) -> str:
|
||||
return "background" if str(workload or "").lower() == "background" else "foreground"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _local_model_slot(target_url: str, model: str, workload: Optional[str] = None):
|
||||
"""Serialize local model traffic, with foreground chat taking priority.
|
||||
|
||||
Most local servers expose one GPU/CPU generation pipe even when their HTTP
|
||||
API accepts multiple requests. Letting scheduled email/tasks and foreground
|
||||
chat hit that pipe together creates the user-visible "streams crossed" and
|
||||
"prompt waited behind a task" failure mode. Cloud providers are left alone.
|
||||
"""
|
||||
if not _local_model_gate_enabled() or not is_local_endpoint(target_url):
|
||||
yield
|
||||
return
|
||||
|
||||
global _LOCAL_MODEL_WAITING_FOREGROUND
|
||||
kind = _gate_workload(workload)
|
||||
current_task = asyncio.current_task()
|
||||
if kind == "foreground":
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND += 1
|
||||
current = dict(_LOCAL_MODEL_CURRENT)
|
||||
if current.get("workload") == "background":
|
||||
task = current.get("task")
|
||||
if isinstance(task, asyncio.Task) and not task.done():
|
||||
logger.info(
|
||||
"[model-gate] cancelling background local model call for foreground request model=%s",
|
||||
model,
|
||||
)
|
||||
task.cancel()
|
||||
else:
|
||||
# Background work should not jump in while the browser/chat is active
|
||||
# or while a foreground request is waiting to acquire the local model.
|
||||
try:
|
||||
from src.interactive_gate import has_foreground_activity
|
||||
except Exception:
|
||||
has_foreground_activity = lambda: False # type: ignore
|
||||
while _LOCAL_MODEL_WAITING_FOREGROUND > 0 or has_foreground_activity():
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
acquired = False
|
||||
try:
|
||||
await _LOCAL_MODEL_LOCK.acquire()
|
||||
acquired = True
|
||||
if kind == "foreground":
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND = max(0, _LOCAL_MODEL_WAITING_FOREGROUND - 1)
|
||||
_LOCAL_MODEL_CURRENT.clear()
|
||||
_LOCAL_MODEL_CURRENT.update({
|
||||
"task": current_task,
|
||||
"workload": kind,
|
||||
"url": target_url,
|
||||
"model": model,
|
||||
"started": time.time(),
|
||||
})
|
||||
yield
|
||||
finally:
|
||||
if kind == "foreground":
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND = max(0, _LOCAL_MODEL_WAITING_FOREGROUND - 1)
|
||||
if acquired and _LOCAL_MODEL_LOCK.locked():
|
||||
owner = _LOCAL_MODEL_CURRENT.get("task")
|
||||
if owner is current_task:
|
||||
_LOCAL_MODEL_CURRENT.clear()
|
||||
_LOCAL_MODEL_LOCK.release()
|
||||
|
||||
class LLMConfig:
|
||||
"""Configuration constants for LLM operations."""
|
||||
DEFAULT_TIMEOUT = 30
|
||||
@@ -199,6 +274,75 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
|
||||
payload["thinking"] = True
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
_DEGENERATE_WORD_RE = re.compile(r"[A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff]+")
|
||||
|
||||
|
||||
class _DegenerateStreamGuard:
|
||||
"""Detect local-model token collapse before it floods the UI.
|
||||
|
||||
Some self-hosted models fail by repeating one token forever ("Var Var Var",
|
||||
"Summer Summer ..."). This is not a useful response and can burn context,
|
||||
browser memory, and GPU time. Keep the guard conservative: only fire on long
|
||||
same-token runs or a very dominant repeated token in the recent window.
|
||||
"""
|
||||
|
||||
def __init__(self, model: str):
|
||||
self.model = model or "model"
|
||||
self.last_token = ""
|
||||
self.same_run = 0
|
||||
self.recent_tokens: List[str] = []
|
||||
self.total_chars = 0
|
||||
|
||||
def check(self, text: str) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
self.total_chars += len(text)
|
||||
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text) if len(t) >= 2]
|
||||
if not tokens:
|
||||
return None
|
||||
for token in tokens:
|
||||
if token == self.last_token:
|
||||
self.same_run += 1
|
||||
else:
|
||||
self.last_token = token
|
||||
self.same_run = 1
|
||||
self.recent_tokens.append(token)
|
||||
if len(self.recent_tokens) > 96:
|
||||
self.recent_tokens = self.recent_tokens[-96:]
|
||||
|
||||
reason = None
|
||||
if self.same_run >= 28 and self.total_chars >= 100:
|
||||
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
||||
elif len(self.recent_tokens) >= 72:
|
||||
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
||||
count = self.recent_tokens.count(top)
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
|
||||
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
||||
if not reason and len(self.recent_tokens) >= 80:
|
||||
# Phrase loops are common on some local quantized MLX/MoE models:
|
||||
# "Also be a software developer mode?" repeated forever will not
|
||||
# trip the single-token guard above, but it is still a wedged
|
||||
# generation. Require many repeats of the same 4-gram so normal
|
||||
# prose/list formatting is not interrupted.
|
||||
grams = [tuple(self.recent_tokens[i:i + 4]) for i in range(0, len(self.recent_tokens) - 3)]
|
||||
if grams:
|
||||
top_gram = max(set(grams), key=grams.count)
|
||||
gram_count = grams.count(top_gram)
|
||||
if gram_count >= 10:
|
||||
reason = f"repeated phrase '{' '.join(top_gram)}' {gram_count} times"
|
||||
|
||||
if not reason:
|
||||
return None
|
||||
|
||||
logger.warning("[degenerate-stream] aborting model=%s reason=%s", self.model, reason)
|
||||
message = (
|
||||
f"Stopped generation: {self.model} started repeating tokens "
|
||||
f"({reason}). Try a different model or lower temperature."
|
||||
)
|
||||
return f'event: error\ndata: {json.dumps({"status": 502, "text": message, "error": message})}\n\n'
|
||||
|
||||
|
||||
def _model_activity_key(url: str, model: str) -> str:
|
||||
return f"{(url or '').strip()}|{(model or '').strip()}"
|
||||
|
||||
@@ -755,6 +899,52 @@ def _apply_local_cache_affinity(payload: Dict, url: str, session_id: Optional[st
|
||||
payload.setdefault("cache_prompt", True)
|
||||
|
||||
|
||||
def _is_local_minimax_mlx_request(url: str, model: str) -> bool:
|
||||
"""Local MLX MiniMax-family endpoints need conservative sampling defaults.
|
||||
|
||||
The OpenAI-compatible MLX server accepts repetition/frequency penalties.
|
||||
Some large quantized MiniMax/MoE ports otherwise fall into visible reasoning
|
||||
loops ("Also be...", "No.", etc.) even for trivial prompts.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
m = model.lower()
|
||||
if "minimax" not in m and "mini-max" not in m:
|
||||
return False
|
||||
try:
|
||||
from src.model_context import is_local_endpoint
|
||||
return is_local_endpoint(url)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _apply_local_generation_stability(payload: Dict, url: str, model: str) -> None:
|
||||
if not _is_local_minimax_mlx_request(url, model):
|
||||
return
|
||||
if "temperature" in payload:
|
||||
try:
|
||||
# MiniMax MLX quantized ports are very sensitive to chat/agent
|
||||
# harness size. Character presets can ask for a warmer voice, but
|
||||
# local MiniMax needs a final compatibility clamp or trivial
|
||||
# prompts can fall into visible reasoning/repetition loops.
|
||||
payload["temperature"] = min(float(payload.get("temperature") or 0.2), 0.2)
|
||||
except (TypeError, ValueError):
|
||||
payload["temperature"] = 0.2
|
||||
payload.setdefault("top_p", 0.9)
|
||||
payload.setdefault("top_k", 20)
|
||||
payload.setdefault("repetition_penalty", 1.12)
|
||||
payload.setdefault("repetition_context_size", 256)
|
||||
payload.setdefault("frequency_penalty", 0.08)
|
||||
payload.setdefault("frequency_context_size", 256)
|
||||
payload.setdefault("presence_penalty", 0.02)
|
||||
payload.setdefault("presence_context_size", 256)
|
||||
payload.setdefault("stop", ["<|im_end|>", "<|endoftext|>", "</s>"])
|
||||
# A max_tokens of 0 means "server default/unbounded" for many local
|
||||
# endpoints. Keep simple chats from running forever when the model loops.
|
||||
if not payload.get("max_tokens") and not payload.get("max_completion_tokens"):
|
||||
payload["max_tokens"] = 2048
|
||||
|
||||
|
||||
def _provider_headers(provider: str, headers: Optional[Dict] = None) -> Dict[str, str]:
|
||||
h = {"Content-Type": "application/json"}
|
||||
if isinstance(headers, dict):
|
||||
@@ -1602,6 +1792,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
if max_tokens and max_tokens > 0:
|
||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||
payload[tok_key] = max_tokens
|
||||
_apply_local_generation_stability(payload, target_url, model)
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
try:
|
||||
@@ -1711,6 +1902,7 @@ async def llm_call_async(
|
||||
max_retries: int = LLMConfig.MAX_RETRIES,
|
||||
prompt_type: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
workload: str = "foreground",
|
||||
) -> str:
|
||||
"""Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging."""
|
||||
provider = _detect_provider(url)
|
||||
@@ -1748,6 +1940,7 @@ async def llm_call_async(
|
||||
max_tokens=max_tokens,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
workload=workload,
|
||||
):
|
||||
event_is_error = False
|
||||
for line in str(chunk).splitlines():
|
||||
@@ -1813,6 +2006,7 @@ async def llm_call_async(
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
_apply_local_cache_affinity(payload, url, session_id)
|
||||
_apply_local_generation_stability(payload, target_url, model)
|
||||
|
||||
if _is_host_dead(target_url):
|
||||
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
||||
@@ -1823,9 +2017,10 @@ async def llm_call_async(
|
||||
attempt += 1
|
||||
start = time.time()
|
||||
try:
|
||||
note_model_activity(target_url, model)
|
||||
client = _get_http_client()
|
||||
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
||||
async with _local_model_slot(target_url, model, workload):
|
||||
note_model_activity(target_url, model)
|
||||
client = _get_http_client()
|
||||
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
||||
duration = time.time() - start
|
||||
if not r.is_success:
|
||||
friendly = _format_upstream_error(r.status_code, r.text, target_url)
|
||||
@@ -1867,11 +2062,45 @@ async def llm_call_async(
|
||||
raise HTTPException(502, f"POST {target_url} failed after {max_retries} attempts: {e}")
|
||||
await asyncio.sleep(LLMConfig.RETRY_DELAY)
|
||||
|
||||
def _stream_target_url(url: str) -> str:
|
||||
provider = _detect_provider(url)
|
||||
if provider == "anthropic":
|
||||
return _normalize_anthropic_url(url)
|
||||
if provider == "ollama":
|
||||
return _normalize_ollama_url(url)
|
||||
if provider == "chatgpt-subscription":
|
||||
return _normalize_chatgpt_subscription_url(url)
|
||||
return _normalize_openai_chat_url(url)
|
||||
|
||||
|
||||
async def stream_llm(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
||||
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
|
||||
tool_choice_none: bool = False):
|
||||
tool_choice_none: bool = False, workload: str = "foreground"):
|
||||
target_url = _stream_target_url(url)
|
||||
async with _local_model_slot(target_url, model, workload):
|
||||
async for chunk in _stream_llm_inner(
|
||||
url,
|
||||
model,
|
||||
messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
prompt_type=prompt_type,
|
||||
tools=tools,
|
||||
session_id=session_id,
|
||||
tool_choice_none=tool_choice_none,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
||||
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
|
||||
tool_choice_none: bool = False):
|
||||
"""Stream LLM responses with improved error handling.
|
||||
|
||||
Yields SSE chunks:
|
||||
@@ -1945,6 +2174,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
|
||||
payload["think"] = False
|
||||
_apply_local_cache_affinity(payload, url, session_id)
|
||||
_apply_local_generation_stability(payload, target_url, model)
|
||||
h = _provider_headers(provider, headers)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
@@ -1961,6 +2191,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
yield f'event: error\ndata: {json.dumps({"error": f"Upstream {_host_key(target_url)} unreachable (cooldown active)", "status": 503})}\n\n'
|
||||
return
|
||||
note_model_activity(target_url, model)
|
||||
degenerate_guard = _DegenerateStreamGuard(model)
|
||||
|
||||
# ── ChatGPT Subscription / Codex Responses streaming ──
|
||||
if provider == "chatgpt-subscription":
|
||||
@@ -1995,6 +2226,10 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
if evt == "response.output_text.delta":
|
||||
delta = data.get("delta") or ""
|
||||
if delta:
|
||||
_degenerate = degenerate_guard.check(delta)
|
||||
if _degenerate:
|
||||
yield _degenerate
|
||||
return
|
||||
yield f'data: {json.dumps({"delta": delta})}\n\n'
|
||||
elif evt == "response.completed":
|
||||
usage = (data.get("response") or {}).get("usage") or data.get("usage") or {}
|
||||
@@ -2327,11 +2562,19 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
reasoning = (reasoning + thinking_part) if reasoning else thinking_part
|
||||
content = text_part
|
||||
if reasoning:
|
||||
_degenerate = degenerate_guard.check(reasoning)
|
||||
if _degenerate:
|
||||
yield _degenerate
|
||||
return
|
||||
yield _stream_delta_event(reasoning, thinking=True)
|
||||
if content:
|
||||
content = _strip_visible_chat_template_artifacts(content)
|
||||
if not content:
|
||||
continue
|
||||
_degenerate = degenerate_guard.check(content)
|
||||
if _degenerate:
|
||||
yield _degenerate
|
||||
return
|
||||
content = re.sub(r"<mm:think(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE)
|
||||
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
|
||||
stripped = content.lstrip()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Shared privilege policy for scheduled task actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
ADMIN_ONLY_TASK_ACTIONS = frozenset({
|
||||
"run_local",
|
||||
"run_script",
|
||||
"ssh_command",
|
||||
"cookbook_serve",
|
||||
})
|
||||
|
||||
|
||||
def is_admin_only_task_action(task_type: str | None, action: str | None) -> bool:
|
||||
return (task_type or "llm") == "action" and (action or "") in ADMIN_ONLY_TASK_ACTIONS
|
||||
|
||||
|
||||
def owner_has_admin_task_privileges(owner: str | None) -> bool:
|
||||
try:
|
||||
from src.auth_helpers import _auth_disabled
|
||||
if _auth_disabled():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if owner:
|
||||
try:
|
||||
from core.middleware import INTERNAL_TOOL_USER
|
||||
if owner == INTERNAL_TOOL_USER:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from core.auth import AuthManager
|
||||
auth = AuthManager()
|
||||
if not auth.is_configured:
|
||||
return True
|
||||
if not owner:
|
||||
return False
|
||||
return bool(auth.is_admin(owner))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not owner:
|
||||
return False
|
||||
|
||||
return False
|
||||
@@ -74,4 +74,5 @@ async def task_llm_call_async(
|
||||
if not candidates:
|
||||
raise RuntimeError("No LLM endpoint available for background task")
|
||||
await wait_for_interactive_quiet("background task LLM")
|
||||
kwargs.setdefault("workload", "background")
|
||||
return await llm_call_async_with_fallback(candidates, messages=messages, **kwargs)
|
||||
|
||||
+51
-2
@@ -10,6 +10,10 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||
|
||||
from core.auth import RESERVED_USERNAMES
|
||||
from src.task_action_policy import (
|
||||
is_admin_only_task_action,
|
||||
owner_has_admin_task_privileges,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -821,6 +825,28 @@ class TaskScheduler:
|
||||
db.commit()
|
||||
return
|
||||
|
||||
if (
|
||||
is_admin_only_task_action(task.task_type, task.action)
|
||||
and not owner_has_admin_task_privileges(task.owner)
|
||||
):
|
||||
msg = f"Action '{task.action}' requires admin privileges"
|
||||
blocked = db.query(TaskRun).filter(TaskRun.id == run_id).first()
|
||||
if blocked:
|
||||
blocked.status = "error"
|
||||
blocked.result = msg
|
||||
blocked.error = msg
|
||||
blocked.finished_at = _utcnow()
|
||||
task.status = "paused"
|
||||
task.next_run = None
|
||||
task.last_run = _utcnow()
|
||||
logger.warning(
|
||||
"Paused admin-only task %s for non-admin owner %r",
|
||||
task_id,
|
||||
task.owner,
|
||||
)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
if gate_foreground:
|
||||
waiting = db.query(TaskRun).filter(TaskRun.id == run_id).first()
|
||||
if waiting and waiting.status == "queued":
|
||||
@@ -868,10 +894,10 @@ class TaskScheduler:
|
||||
# Give the just-finished quiet gate a tiny grace window,
|
||||
# then keep enforcing "background means background" while
|
||||
# a long email/LLM action is already running.
|
||||
await asyncio.sleep(1.0)
|
||||
await asyncio.sleep(0.1)
|
||||
from src.interactive_gate import has_foreground_activity
|
||||
while True:
|
||||
await asyncio.sleep(1.0)
|
||||
await asyncio.sleep(0.25)
|
||||
if has_foreground_activity():
|
||||
foreground_cancel["hit"] = True
|
||||
logger.info("Task '%s' interrupted because Odysseus became active", task.name)
|
||||
@@ -1887,6 +1913,7 @@ class TaskScheduler:
|
||||
disabled_tools=disabled_tools,
|
||||
relevant_tools=relevant_tools,
|
||||
fallbacks=_task_fallbacks,
|
||||
workload="background",
|
||||
):
|
||||
if event_str.startswith("data: ") and not event_str.startswith("data: [DONE]"):
|
||||
try:
|
||||
@@ -2213,6 +2240,28 @@ class TaskScheduler:
|
||||
stopped = self._mark_run_aborted(task_id) or stopped
|
||||
return stopped
|
||||
|
||||
async def stop_background_tasks_for_foreground(self, *, reason: str = "Odysseus became active") -> int:
|
||||
"""Cancel all in-process scheduler tasks because the user is active.
|
||||
|
||||
This is intentionally blunt for scheduled/background work: when the
|
||||
user opens or uses Odysseus, foreground interaction wins immediately.
|
||||
Manual force-runs can be restarted by the user; automatic jobs will be
|
||||
deferred by their cancellation path instead of stealing the app.
|
||||
"""
|
||||
async with self._executing_lock:
|
||||
task_ids = list(self._executing)
|
||||
stopped = 0
|
||||
for task_id in task_ids:
|
||||
handle = self._task_handles.get(task_id)
|
||||
if handle and not handle.done():
|
||||
handle.cancel()
|
||||
stopped += 1
|
||||
if self._mark_run_aborted(task_id):
|
||||
stopped += 1
|
||||
if stopped:
|
||||
logger.info("Stopped %d background scheduler task(s): %s", stopped, reason)
|
||||
return stopped
|
||||
|
||||
async def ensure_defaults(self, owner: str):
|
||||
"""Create default housekeeping tasks for this owner (idempotent per action)."""
|
||||
from core.database import SessionLocal, ScheduledTask
|
||||
|
||||
+4
-2
@@ -405,8 +405,10 @@ class ToolIndex:
|
||||
{"chat_with_model", "ask_teacher", "list_models"},
|
||||
# Deep research intent (incl. common typo "reserach")
|
||||
frozenset({"web search", "search the web", "search online", "look up",
|
||||
"google", "latest", "current", "news", "weather",
|
||||
"forecast", "stock price", "price of"}):
|
||||
"find info online", "find information online",
|
||||
"find info", "find information", "online about",
|
||||
"on the internet", "google", "latest", "current", "news",
|
||||
"weather", "forecast", "stock price", "price of"}):
|
||||
{"web_search", "web_fetch"},
|
||||
frozenset({"research", "reserach", "reasearch", "look into", "investigate",
|
||||
"deep dive", "deep research", "find out about", "study up on",
|
||||
|
||||
@@ -172,6 +172,27 @@ _GEMMA_TOOL_CALL_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Pattern 4c: Open-function wrapper emitted by some local MLX/Exo models.
|
||||
# Example:
|
||||
# <function_model>
|
||||
# <function_call>web_search</function_call>
|
||||
# <parameters>{"query":"Sweden news today"}</parameters>
|
||||
# </function_model>
|
||||
_FUNCTION_MODEL_OPEN_RE = re.compile(r"<function_model>\s*", re.IGNORECASE)
|
||||
_FUNCTION_MODEL_CLOSE_RE = re.compile(r"</function_model>", re.IGNORECASE)
|
||||
_FUNCTION_MODEL_NAME_RE = re.compile(
|
||||
r"<function_call>\s*([A-Za-z_][\w-]*)\s*</function_call>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"<parameters>\s*", re.IGNORECASE)
|
||||
_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r"</parameters>", re.IGNORECASE)
|
||||
_QWEN_ROLE_MARKER_RE = re.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", re.IGNORECASE)
|
||||
_QWEN_BARE_MARKER_RE = re.compile(
|
||||
r"(?:^|[\t\r\n ])(?:\|?end\|?|/?\|end\|)(?=[\t\r\n ]|$)|"
|
||||
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
# Pattern 5: DeepSeek DSML markup leaking into content. When deepseek
|
||||
# models can't emit structured tool_calls (e.g. we sent no tool schemas
|
||||
@@ -566,6 +587,205 @@ def _parse_raw_web_json_lookup(text: str) -> Optional[tuple[ToolBlock, tuple[int
|
||||
return block, (start, start + end)
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_openai_tool_call_blob(value) -> bool:
|
||||
"""Return True for raw OpenAI-style tool-call JSON leaked as text."""
|
||||
if isinstance(value, list):
|
||||
return bool(value) and all(_looks_like_openai_tool_call_blob(item) for item in value)
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
fn = value.get("function")
|
||||
if isinstance(fn, dict) and isinstance(fn.get("name"), str):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _raw_openai_tool_call_to_block(value) -> Optional[ToolBlock]:
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
block = _raw_openai_tool_call_to_block(item)
|
||||
if block:
|
||||
return block
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
fn = value.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
return None
|
||||
name = str(fn.get("name") or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
tool_type = _TOOL_NAME_MAP.get(name, name)
|
||||
raw_args = fn.get("arguments") or {}
|
||||
try:
|
||||
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
# Common local-model typo seen in raw OpenAI JSON leaks.
|
||||
if "text" not in args and "tex" in args:
|
||||
args["text"] = args.get("tex")
|
||||
|
||||
if tool_type.startswith("mcp__"):
|
||||
return ToolBlock(tool_type, json.dumps(args) if args else "{}")
|
||||
if name in BUILTIN_EMAIL_TOOLS:
|
||||
return ToolBlock(f"mcp__email__{name}", json.dumps(args) if args else "{}")
|
||||
if tool_type not in TOOL_TAGS:
|
||||
return None
|
||||
|
||||
if tool_type == "bash":
|
||||
content = args.get("command", "")
|
||||
elif tool_type == "python":
|
||||
content = args.get("code", "")
|
||||
elif tool_type == "web_search":
|
||||
content = args.get("query", "")
|
||||
queries = args.get("queries")
|
||||
if not content and isinstance(queries, list) and queries:
|
||||
content = str(queries[0])
|
||||
elif not content and queries:
|
||||
content = str(queries)
|
||||
tf = args.get("time_filter")
|
||||
if content and isinstance(tf, str) and tf in ("day", "week", "month", "year"):
|
||||
content = json.dumps({"query": content, "time_filter": tf})
|
||||
elif tool_type == "web_fetch":
|
||||
content = args.get("url") or args.get("domain") or ""
|
||||
elif tool_type == "read_file":
|
||||
content = json.dumps(args) if (args.get("offset") or args.get("limit")) else args.get("path", "")
|
||||
elif tool_type in ("grep", "glob", "ls", "edit_file"):
|
||||
content = json.dumps(args) if args else "{}"
|
||||
elif tool_type == "write_file":
|
||||
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||
elif tool_type == "create_document":
|
||||
parts = [args.get("title", "Untitled")]
|
||||
if args.get("language"):
|
||||
parts.append(args["language"])
|
||||
parts.append(args.get("content", ""))
|
||||
content = "\n".join(parts)
|
||||
elif tool_type == "update_document":
|
||||
content = args.get("content", "")
|
||||
elif tool_type in ("edit_document", "suggest_document"):
|
||||
marker = "SUGGEST" if tool_type == "suggest_document" else "REPLACE"
|
||||
blocks = []
|
||||
for edit in args.get("suggestions" if tool_type == "suggest_document" else "edits", []) or []:
|
||||
if not isinstance(edit, dict):
|
||||
continue
|
||||
block = f'<<<FIND>>>\n{edit.get("find", "")}\n<<<{marker}>>>\n{edit.get("replace", "")}'
|
||||
if tool_type == "suggest_document":
|
||||
block += f'\n<<<REASON>>>\n{edit.get("reason", "")}'
|
||||
blocks.append(block + "\n<<<END>>>")
|
||||
content = "\n".join(blocks)
|
||||
elif tool_type == "search_chats":
|
||||
content = args.get("query", "")
|
||||
elif tool_type == "chat_with_model":
|
||||
content = args.get("model", "") + "\n" + args.get("message", "")
|
||||
elif tool_type == "create_session":
|
||||
content = args.get("name", "Untitled") + "\n" + args.get("model", "")
|
||||
elif tool_type == "list_sessions":
|
||||
content = args.get("filter", "")
|
||||
elif tool_type == "send_to_session":
|
||||
content = args.get("session_id", "") + "\n" + args.get("message", "")
|
||||
elif tool_type == "pipeline":
|
||||
content = json.dumps({"steps": args.get("steps", [])})
|
||||
elif tool_type == "manage_session":
|
||||
action = args.get("action", "")
|
||||
if action == "list":
|
||||
keyword = args.get("keyword", "") or args.get("value", "")
|
||||
content = "list" + (("\n" + keyword) if keyword and keyword.lower() != "current" else "")
|
||||
else:
|
||||
content = action + "\n" + args.get("session_id", "current")
|
||||
if args.get("value"):
|
||||
content += "\n" + args["value"]
|
||||
elif tool_type == "manage_memory":
|
||||
action = args.get("action", "")
|
||||
if action == "add":
|
||||
content = "add\n" + str(args.get("text", ""))
|
||||
if args.get("category"):
|
||||
content += "\n" + str(args["category"])
|
||||
elif action == "edit":
|
||||
content = "edit\n" + str(args.get("memory_id", "")) + "\n" + str(args.get("text", ""))
|
||||
elif action == "delete":
|
||||
content = "delete\n" + str(args.get("memory_id", ""))
|
||||
elif action == "search":
|
||||
content = "search\n" + str(args.get("text", ""))
|
||||
elif action == "list":
|
||||
content = "list" + (("\n" + str(args["category"])) if args.get("category") else "")
|
||||
else:
|
||||
content = action
|
||||
elif tool_type == "ui_control":
|
||||
action = args.get("action", "")
|
||||
name_arg = args.get("name", "")
|
||||
value = args.get("value", "")
|
||||
if action == "open_panel":
|
||||
content = f"open_panel {name_arg or value}"
|
||||
elif action == "toggle":
|
||||
content = f"toggle {name_arg} {value}"
|
||||
else:
|
||||
content = action
|
||||
elif tool_type in ("manage_tasks", "manage_skills", "api_call", "manage_endpoints",
|
||||
"manage_mcp", "manage_webhooks", "manage_tokens",
|
||||
"manage_documents", "manage_settings", "manage_notes",
|
||||
"manage_research", "manage_bg_jobs"):
|
||||
content = json.dumps(args)
|
||||
elif tool_type in ("get_workspace", "list_models"):
|
||||
content = args.get("filter", "") if tool_type == "list_models" else ""
|
||||
else:
|
||||
content = json.dumps(args) if args else ""
|
||||
return ToolBlock(tool_type, str(content or ""))
|
||||
|
||||
|
||||
def _parse_raw_openai_tool_call_json(text: str) -> Optional[ToolBlock]:
|
||||
if not isinstance(text, str) or '"function"' not in text:
|
||||
return None
|
||||
decoder = json.JSONDecoder()
|
||||
for match in re.finditer(r"[\[{]", text):
|
||||
try:
|
||||
parsed, _end = decoder.raw_decode(text[match.start():])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
block = _raw_openai_tool_call_to_block(parsed)
|
||||
if block:
|
||||
return block
|
||||
return None
|
||||
|
||||
|
||||
def _strip_raw_openai_tool_call_json(text: str) -> str:
|
||||
"""Strip raw JSON tool calls such as {"function": {...}, "type": "function"}.
|
||||
|
||||
Some local models emit native tool-call JSON into assistant text. The agent
|
||||
can still parse/execute it through the native path, but the raw payload must
|
||||
not render or persist as prose.
|
||||
"""
|
||||
if not isinstance(text, str) or '"function"' not in text:
|
||||
return text
|
||||
decoder = json.JSONDecoder()
|
||||
pieces = []
|
||||
pos = 0
|
||||
changed = False
|
||||
for match in re.finditer(r"[\[{]", text):
|
||||
start = match.start()
|
||||
if start < pos:
|
||||
continue
|
||||
try:
|
||||
parsed, rel_end = decoder.raw_decode(text[start:])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
end = start + rel_end
|
||||
if not _looks_like_openai_tool_call_blob(parsed):
|
||||
continue
|
||||
pieces.append(text[pos:start])
|
||||
pos = end
|
||||
changed = True
|
||||
# Common broken local-model suffix: a standalone ] before a role marker.
|
||||
while pos < len(text) and text[pos] in " \t\r\n":
|
||||
pos += 1
|
||||
if pos < len(text) and text[pos] == "]":
|
||||
pos += 1
|
||||
if not changed:
|
||||
return text
|
||||
pieces.append(text[pos:])
|
||||
return "".join(pieces)
|
||||
|
||||
def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]:
|
||||
"""Parse a [TOOL_CALL] block into a ToolBlock.
|
||||
|
||||
@@ -885,6 +1105,24 @@ def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
|
||||
return function_call_to_tool_block(tool_name, json.dumps(params))
|
||||
|
||||
|
||||
def _parse_function_model_call(body: str) -> Optional[ToolBlock]:
|
||||
"""Parse <function_model><function_call>tool</...><parameters>...</...>."""
|
||||
name_match = _FUNCTION_MODEL_NAME_RE.search(body or "")
|
||||
if not name_match:
|
||||
return None
|
||||
tool_name = name_match.group(1).strip().lower().replace("-", "_")
|
||||
params = "{}"
|
||||
for _ms, inner_start, inner_end, _me in _iter_delimited(
|
||||
body,
|
||||
_FUNCTION_MODEL_PARAMS_OPEN_RE,
|
||||
_FUNCTION_MODEL_PARAMS_CLOSE_RE,
|
||||
):
|
||||
params = body[inner_start:inner_end].strip() or "{}"
|
||||
break
|
||||
from src.tool_schemas import function_call_to_tool_block
|
||||
return function_call_to_tool_block(tool_name, params)
|
||||
|
||||
|
||||
def _iter_delimited(text, open_re, close_re):
|
||||
"""Yield ``(match_start, inner_start, inner_end, match_end)`` for each
|
||||
non-overlapping ``open_re ... close_re`` pair, scanning strictly forward.
|
||||
@@ -1136,6 +1374,22 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
||||
if block:
|
||||
blocks.append(block)
|
||||
|
||||
# Pattern 4c: <function_model> wrapper from local MLX/Exo models.
|
||||
if not blocks:
|
||||
for _ms, inner_start, inner_end, _me in _iter_delimited(
|
||||
text, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE
|
||||
):
|
||||
block = _parse_function_model_call(text[inner_start:inner_end])
|
||||
if block:
|
||||
blocks.append(block)
|
||||
|
||||
# Pattern 4d: raw OpenAI-style tool-call JSON leaked as assistant text.
|
||||
# Example: {"function":{"arguments":"{\"action\":\"add\"}","name":"manage_memory"},"type":"function"}
|
||||
if not blocks:
|
||||
block = _parse_raw_openai_tool_call_json(text)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
|
||||
# Pattern 6: local text-model web_search call leaked as prose + bare JSON.
|
||||
if not blocks and not skip_fenced:
|
||||
raw_web_json = _parse_raw_web_json_lookup(text)
|
||||
@@ -1182,6 +1436,10 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
|
||||
cleaned = _XML_OPEN_TOOL_CALL_RE.sub('', cleaned)
|
||||
cleaned = _strip_delimited(cleaned, _TOOL_CODE_OPEN_RE, _TOOL_CODE_CLOSE_RE)
|
||||
cleaned = _GEMMA_TOOL_CALL_RE.sub('', cleaned)
|
||||
cleaned = _strip_delimited(cleaned, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE)
|
||||
cleaned = _strip_raw_openai_tool_call_json(cleaned)
|
||||
cleaned = _QWEN_ROLE_MARKER_RE.sub('', cleaned)
|
||||
cleaned = _QWEN_BARE_MARKER_RE.sub(' ', cleaned)
|
||||
if not skip_fenced:
|
||||
raw_web_json = _parse_raw_web_json_lookup(cleaned)
|
||||
if raw_web_json:
|
||||
|
||||
@@ -16,6 +16,39 @@ GUIDE_ONLY_DIRECTIVE = (
|
||||
"output they will produce locally."
|
||||
)
|
||||
|
||||
WEB_TOOL_NAMES = frozenset({"web_search", "web_fetch"})
|
||||
|
||||
|
||||
def tool_toggle_enabled(value: object) -> bool:
|
||||
"""Return true only for explicit true-like tool toggle values."""
|
||||
|
||||
return str(value).lower() == "true"
|
||||
|
||||
|
||||
def tool_toggle_explicitly_denied(value: object) -> bool:
|
||||
"""Return true when a caller explicitly supplied a non-true toggle value."""
|
||||
|
||||
return value is not None and not tool_toggle_enabled(value)
|
||||
|
||||
|
||||
def is_web_search_explicitly_denied(allow_web_search: object) -> bool:
|
||||
"""Whether the web-search agent toggle was explicitly set to false."""
|
||||
|
||||
return tool_toggle_explicitly_denied(allow_web_search)
|
||||
|
||||
|
||||
def web_search_enabled_for_turn(allow_web_search: object, use_web: object = None) -> bool:
|
||||
"""Return true only when this request explicitly enables web search.
|
||||
|
||||
Agent mode sends ``allow_web_search``; chat-mode pre-search sends
|
||||
``use_web``. If both are present, an explicit ``allow_web_search=false``
|
||||
wins so a stale or conflicting intent path cannot re-enable web tools.
|
||||
"""
|
||||
|
||||
if is_web_search_explicitly_denied(allow_web_search):
|
||||
return False
|
||||
return tool_toggle_enabled(allow_web_search) or tool_toggle_enabled(use_web)
|
||||
|
||||
|
||||
_COMMON_TOOL_NAMES = {
|
||||
"api_call",
|
||||
|
||||
+30
-10
@@ -18,6 +18,15 @@ from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_REQUIRED_NATIVE_TOOL_ARGS = {
|
||||
"web_search": ("query", "queries"),
|
||||
"web_fetch": ("url",),
|
||||
"read_file": ("path",),
|
||||
"write_file": ("path",),
|
||||
"edit_file": ("path",),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI-compatible function tool schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -187,7 +196,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_document",
|
||||
"description": "Create a new document in the editor panel. Use this when the user asks to write, create, build, or generate code, scripts, programs, games, apps, or any substantial content (>15 lines) AND there is no already-open document/email draft that the request refers to. If an email compose draft is open, edit that draft instead of creating another document. NEVER put large code blocks directly in chat — use this tool instead.",
|
||||
"description": "Create a new document in the editor panel. Use this when the user asks to write, create, build, make, or generate code, scripts, programs, games, apps, or any long-form or structured content that is more than a short paragraph, AND there is no already-open document/email draft that the request refers to. If an email compose draft is open, edit that draft instead of creating another document. NEVER put large generated content directly in chat — use this tool instead.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -556,8 +565,8 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"uid": {"type": "string", "description": "Event UID (for update/delete)"},
|
||||
"calendar_href": {"type": "string", "description": "Specific calendar URL (optional; defaults to first calendar)"},
|
||||
"calendar": {"type": "string", "description": "Filter list_events by calendar name or href"},
|
||||
"start": {"type": "string", "description": "list_events range start (ISO datetime); defaults to today. Prefer start; backend also accepts start_date, range_start, from, dtstart, since."},
|
||||
"end": {"type": "string", "description": "list_events range end (ISO datetime); defaults to +14 days. Prefer end; backend also accepts end_date, range_end, to, dtend, until."},
|
||||
"start": {"type": "string", "description": "list_events range start (ISO datetime). Use this for month/week requests after resolving the date range; do not pass a loose query string. Prefer start; backend also accepts start_time, start_date, range_start, from, dtstart, since."},
|
||||
"end": {"type": "string", "description": "list_events range end (ISO datetime). Use this for month/week requests after resolving the date range; defaults to +14 days only when no range is requested. Prefer end; backend also accepts end_time, end_date, range_end, to, dtend, until."},
|
||||
"event_type": {"type": "string", "description": "Tag / category for the event. Common values: work, personal, health, travel, meal, social, admin, other. Aliases accepted: tag, category, type."},
|
||||
"importance": {"type": "string", "enum": ["low", "normal", "high", "critical"], "description": "Priority level (defaults to 'normal')"},
|
||||
"reminder_minutes": {"type": "integer", "description": "For create_event: create an Odysseus reminder this many minutes before the event, e.g. 5 for 'reminder 5 min before'."},
|
||||
@@ -576,9 +585,10 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string",
|
||||
"enum": ["list", "view", "add", "update", "delete", "toggle_item"],
|
||||
"enum": ["list", "search", "view", "add", "update", "delete", "toggle_item"],
|
||||
"description": "The action to perform"},
|
||||
"id": {"type": "string", "description": "Note id (for update/delete/toggle_item); 8-char prefix is fine"},
|
||||
"query": {"type": "string", "description": "Search text for action='search'"},
|
||||
"title": {"type": "string", "description": "Note title (for add/update)"},
|
||||
"content": {"type": "string", "description": "Freeform body text. Use this for note_type='note'. Do NOT use this for checklists — pass `checklist_items` instead."},
|
||||
"note_type": {"type": "string", "enum": ["note", "checklist"],
|
||||
@@ -1025,7 +1035,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "manage_contact",
|
||||
"description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact, update an existing one (email/phone/address), or remove one. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.",
|
||||
"description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact, update an existing one (email/phone/address), or remove one. Add does not require email: name + phone or name + address is valid. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1033,9 +1043,9 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"description": "list = show all contacts (with uids); add = create; update = edit by uid; delete = remove by uid."},
|
||||
"uid": {"type": "string", "description": "Contact UID (required for update/delete; get it from action=list)."},
|
||||
"name": {"type": "string", "description": "Contact's display name (for add/update)."},
|
||||
"email": {"type": "string", "description": "Single email address (convenience for add, or the primary email for update)."},
|
||||
"emails": {"type": "array", "items": {"type": "string"}, "description": "Full list of email addresses (for update; first is primary)."},
|
||||
"phones": {"type": "array", "items": {"type": "string"}, "description": "Full list of phone numbers (for update)."},
|
||||
"email": {"type": "string", "description": "Single email address (convenience for add, or the primary email for update). Optional when phone or address is provided."},
|
||||
"emails": {"type": "array", "items": {"type": "string"}, "description": "Full list of email addresses (first is primary)."},
|
||||
"phones": {"type": "array", "items": {"type": "string"}, "description": "Full list of phone numbers. Valid for add/update."},
|
||||
"address": {"type": "string", "description": "Postal/mailing address as a single human-readable string."},
|
||||
},
|
||||
"required": ["action"]
|
||||
@@ -1308,6 +1318,11 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
logger.warning(f"Non-object function call arguments for {name}: {args!r}; treating as empty")
|
||||
args = {}
|
||||
|
||||
required_args = _REQUIRED_NATIVE_TOOL_ARGS.get(tool_type)
|
||||
if required_args and not any(str(args.get(key) or "").strip() for key in required_args):
|
||||
logger.warning(f"Rejecting empty required arguments for function call {name}: {args!r}")
|
||||
return None
|
||||
|
||||
# Allow MCP tools through (namespaced as mcp__serverid__toolname)
|
||||
if tool_type.startswith("mcp__"):
|
||||
content = json.dumps(args) if args else "{}"
|
||||
@@ -1415,15 +1430,20 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
elif tool_type == "manage_memory":
|
||||
action = args.get("action", "")
|
||||
if action == "add":
|
||||
content = "add\n" + args.get("text", "")
|
||||
text = args.get("text") or args.get("value") or args.get("content") or ""
|
||||
if not text and args.get("key"):
|
||||
text = str(args.get("key") or "")
|
||||
content = "add\n" + str(text)
|
||||
if args.get("category"):
|
||||
content += "\n" + args["category"]
|
||||
elif args.get("key"):
|
||||
content += "\n" + str(args["key"])
|
||||
elif action == "edit":
|
||||
content = "edit\n" + args.get("memory_id", "") + "\n" + args.get("text", "")
|
||||
elif action == "delete":
|
||||
content = "delete\n" + args.get("memory_id", "")
|
||||
elif action == "search":
|
||||
content = "search\n" + args.get("text", "")
|
||||
content = "search\n" + (args.get("text") or args.get("tex") or args.get("query") or "")
|
||||
elif action == "list":
|
||||
content = "list"
|
||||
if args.get("category"):
|
||||
|
||||
+11
-2
@@ -208,11 +208,20 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
elif action == "list_events":
|
||||
try:
|
||||
start_raw = _first_nonempty_arg(
|
||||
"start", "start_date", "range_start", "from", "dtstart", "since"
|
||||
"start", "start_time", "start_date", "range_start", "from", "dtstart", "since"
|
||||
)
|
||||
end_raw = _first_nonempty_arg(
|
||||
"end", "end_date", "range_end", "to", "dtend", "until"
|
||||
"end", "end_time", "end_date", "range_end", "to", "dtend", "until"
|
||||
)
|
||||
query_raw = args.get("query") or args.get("date_range") or args.get("range")
|
||||
if query_raw and (not start_raw or not end_raw):
|
||||
return {
|
||||
"error": (
|
||||
"list_events needs explicit start/end ISO datetimes; "
|
||||
f"resolve the requested range ({query_raw!r}) and call manage_calendar again."
|
||||
),
|
||||
"exit_code": 1,
|
||||
}
|
||||
if start_raw:
|
||||
start_dt = _parse_dt(start_raw)
|
||||
else:
|
||||
|
||||
+23
-10
@@ -108,16 +108,28 @@ async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
if action == "add":
|
||||
email = (args.get("email") or "").strip()
|
||||
if not email:
|
||||
return {"error": "email is required for add", "exit_code": 1}
|
||||
name = (args.get("name") or "").strip() or email.split("@")[0]
|
||||
# Dedupe by email (same as the /add route).
|
||||
phones = [str(p or "").strip() for p in (args.get("phones") or []) if str(p or "").strip()]
|
||||
phone = (args.get("phone") or "").strip()
|
||||
if phone and phone not in phones:
|
||||
phones.insert(0, phone)
|
||||
address = (args.get("address") or "").strip()
|
||||
name = (args.get("name") or "").strip()
|
||||
if not name and email:
|
||||
name = email.split("@")[0]
|
||||
if not name and not email and not phones and not address:
|
||||
return {"error": "name plus email, phone, or address is required for add", "exit_code": 1}
|
||||
if not name:
|
||||
name = email.split("@")[0] if email else (phones[0] if phones else "Contact")
|
||||
# Dedupe by email or phone (same as the /add route).
|
||||
existing = await asyncio.to_thread(cc._fetch_contacts)
|
||||
for c in existing:
|
||||
if email.lower() in [e.lower() for e in c.get("emails", [])]:
|
||||
if email and email.lower() in [e.lower() for e in c.get("emails", [])]:
|
||||
return {"output": f"{email} is already a contact ({c.get('name','')}).", "exit_code": 0}
|
||||
ok = await asyncio.to_thread(cc._create_contact, name, email)
|
||||
return {"output": f"{'Added' if ok else 'Failed to add'} {name} <{email}>.", "exit_code": 0 if ok else 1}
|
||||
if phones and any(p in (c.get("phones") or []) for p in phones):
|
||||
return {"output": f"{phones[0]} is already a contact ({c.get('name','')}).", "exit_code": 0}
|
||||
ok = await asyncio.to_thread(cc._create_contact, name, email, address, phones)
|
||||
detail = email or ", ".join(phones) or address
|
||||
return {"output": f"{'Added' if ok else 'Failed to add'} {name} ({detail}).", "exit_code": 0 if ok else 1}
|
||||
|
||||
if action in ("update", "edit"):
|
||||
uid = (args.get("uid") or "").strip()
|
||||
@@ -129,11 +141,12 @@ async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict:
|
||||
emails = [args["email"]]
|
||||
emails = [e.strip() for e in (emails or []) if e and e.strip()]
|
||||
phones = [p.strip() for p in (args.get("phones") or []) if p and p.strip()]
|
||||
if not name and not emails:
|
||||
return {"error": "Provide a name or emails to update", "exit_code": 1}
|
||||
address = (args.get("address") or "").strip()
|
||||
if not name and not emails and not phones and not address:
|
||||
return {"error": "Provide a name, emails, phones, or address to update", "exit_code": 1}
|
||||
if not name and emails:
|
||||
name = emails[0].split("@")[0]
|
||||
ok = await asyncio.to_thread(cc._update_contact, uid, name, emails, phones)
|
||||
ok = await asyncio.to_thread(cc._update_contact, uid, name, emails, phones, address)
|
||||
return {"output": "Contact updated." if ok else "Update failed.", "exit_code": 0 if ok else 1}
|
||||
|
||||
if action == "delete":
|
||||
|
||||
@@ -315,6 +315,7 @@ async def _cookbook_register_task(
|
||||
_MODEL_PROCESS_PATTERNS = [
|
||||
("vLLM", ["vllm.entrypoints", "vllm serve", "/vllm/", "vllm-openai"]),
|
||||
("SGLang", ["sglang.launch_server", "sglang/launch_server"]),
|
||||
("MLX", ["mlx_lm.server", "mlx-lm"]),
|
||||
("llama.cpp", ["llama-server", "llama_cpp_server", "llamacppserver"]),
|
||||
("Ollama", ["ollama serve", "ollama runner", "/ollama "]),
|
||||
("ComfyUI", ["comfyui/main.py", "/ComfyUI/main.py", "ComfyUI"]),
|
||||
@@ -590,7 +591,7 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict:
|
||||
hint = ""
|
||||
if isinstance(err_msg, str) and "cmd" in err_msg.lower():
|
||||
hint = (" — the cmd must START with an allowlisted binary "
|
||||
"(vllm, python3, llama-server, ollama, sglang, lmdeploy, node, npx). "
|
||||
"(vllm, python3, llama-server, ollama, sglang, mlx_lm, lmdeploy, node, npx). "
|
||||
"Do NOT prefix with `cd …`, `source …`, or chain with `&&`. "
|
||||
"env_prefix (e.g. `source ~/qwen35-env/bin/activate`) is added "
|
||||
"automatically from the host's saved venv settings.")
|
||||
@@ -635,7 +636,7 @@ async def do_list_served_models(content: str, owner: Optional[str] = None) -> Di
|
||||
|
||||
if not merged:
|
||||
return {
|
||||
"output": "No model servers currently running (cookbook task tracker empty; /proc scan found no vLLM / sglang / llama.cpp / Ollama / ComfyUI / A1111 / Fooocus / InvokeAI / TGI / Aphrodite / Triton / Diffusers processes).",
|
||||
"output": "No model servers currently running (cookbook task tracker empty; /proc scan found no vLLM / sglang / MLX / llama.cpp / Ollama / ComfyUI / A1111 / Fooocus / InvokeAI / TGI / Aphrodite / Triton / Diffusers processes).",
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
|
||||
+76
-25
@@ -27,7 +27,8 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
# Action aliases — match what models actually emit. `create` is the most
|
||||
# common alternative to `add`. Hyphenated forms also accepted.
|
||||
action = (args.get("action") or "").replace("-", "_").strip().lower()
|
||||
raw_action = (args.get("action") or "").replace("-", "_").strip().lower()
|
||||
action = raw_action
|
||||
_NOTE_ACTION_ALIASES = {
|
||||
"create": "add",
|
||||
"new": "add",
|
||||
@@ -60,37 +61,68 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
q = q.filter(Note.owner == owner)
|
||||
return q.first()
|
||||
|
||||
def _format_note_list(notes) -> str:
|
||||
lines = []
|
||||
for n in notes:
|
||||
pin = " [PINNED]" if n.pinned else ""
|
||||
typ = " [checklist]" if n.note_type == "checklist" else ""
|
||||
lbl = f" #{n.label}" if n.label else ""
|
||||
title = n.title or "(untitled)"
|
||||
lines.append(f"- [{n.id[:8]}] **{title}**{pin}{typ}{lbl}")
|
||||
if n.note_type == "checklist" and n.items:
|
||||
try:
|
||||
items = json.loads(n.items)
|
||||
for i, item in enumerate(items):
|
||||
mark = "x" if item.get("done") else " "
|
||||
lines.append(f" [{mark}] {i}: {item.get('text', '')}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
elif n.content:
|
||||
snippet = n.content[:80].replace("\n", " ")
|
||||
lines.append(f" {snippet}")
|
||||
return "\n".join(lines)
|
||||
|
||||
try:
|
||||
if action == "list":
|
||||
if action in ("list", "search", "find"):
|
||||
q = db.query(Note)
|
||||
if owner is not None:
|
||||
q = q.filter(Note.owner == owner)
|
||||
if args.get("label"):
|
||||
q = q.filter(Note.label == args["label"])
|
||||
label_filter = str(args.get("label") or "").strip()
|
||||
if label_filter and label_filter.lower() != "default":
|
||||
q = q.filter(Note.label == label_filter)
|
||||
show_archived = args.get("archived", False)
|
||||
q = q.filter(Note.archived == show_archived)
|
||||
notes = q.order_by(Note.pinned.desc(), Note.updated_at.desc()).all()
|
||||
if action in ("search", "find"):
|
||||
query = str(
|
||||
args.get("query")
|
||||
or args.get("text")
|
||||
or args.get("title")
|
||||
or args.get("content")
|
||||
or ""
|
||||
).strip().lower()
|
||||
if query:
|
||||
filtered = []
|
||||
for n in notes:
|
||||
haystack = " ".join(
|
||||
str(part or "")
|
||||
for part in (n.title, n.content, n.label, n.items)
|
||||
).lower()
|
||||
if query in haystack:
|
||||
filtered.append(n)
|
||||
notes = filtered
|
||||
if not notes:
|
||||
return {"response": "No notes found.", "exit_code": 0}
|
||||
lines = []
|
||||
for n in notes:
|
||||
pin = " [PINNED]" if n.pinned else ""
|
||||
typ = " [checklist]" if n.note_type == "checklist" else ""
|
||||
lbl = f" #{n.label}" if n.label else ""
|
||||
title = n.title or "(untitled)"
|
||||
lines.append(f"- [{n.id[:8]}] **{title}**{pin}{typ}{lbl}")
|
||||
if n.note_type == "checklist" and n.items:
|
||||
try:
|
||||
items = json.loads(n.items)
|
||||
for i, item in enumerate(items):
|
||||
mark = "x" if item.get("done") else " "
|
||||
lines.append(f" [{mark}] {i}: {item.get('text', '')}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
elif n.content:
|
||||
snippet = n.content[:80].replace("\n", " ")
|
||||
lines.append(f" {snippet}")
|
||||
return {"results": "\n".join(lines)}
|
||||
return {"results": _format_note_list(notes), "exit_code": 0}
|
||||
|
||||
elif action == "view":
|
||||
note_id = args.get("id", "")
|
||||
note = _note_by_prefix(note_id)
|
||||
if not note:
|
||||
return {"error": f"Note '{note_id}' not found", "exit_code": 1}
|
||||
if not _note_visible_to_owner(note, owner):
|
||||
return {"error": "Note not found", "exit_code": 1}
|
||||
return {"results": _format_note_list([note]), "exit_code": 0}
|
||||
|
||||
elif action == "add":
|
||||
# Accept the various field names models emit: `text` is the most
|
||||
@@ -120,6 +152,25 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
# `new Date()` resolves the right absolute moment regardless of
|
||||
# where the user is.
|
||||
due_raw = args.get("due_date")
|
||||
if not due_raw:
|
||||
combined_text = " ".join(
|
||||
str(v or "")
|
||||
for v in (title, content_raw, text_raw)
|
||||
).strip()
|
||||
lower_combined = combined_text.lower()
|
||||
looks_like_reminder = (
|
||||
raw_action in {"remind", "reminder"}
|
||||
or re.search(r"\bremind(?:er)?\b", lower_combined)
|
||||
)
|
||||
if looks_like_reminder:
|
||||
temporal = re.search(
|
||||
r"\b(?:today|tonight|tomorrow|tmrw|yesterday)\b(?:\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?"
|
||||
r"|\b\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
|
||||
r"|\bin\s+\d+\s*(?:hour|hr|minute|min|day)s?\b",
|
||||
lower_combined,
|
||||
)
|
||||
if temporal:
|
||||
due_raw = temporal.group(0)
|
||||
due_iso = None
|
||||
if due_raw:
|
||||
try:
|
||||
@@ -170,7 +221,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
# link with no target, leaving the user with a click that
|
||||
# did nothing and uncertainty about whether the note was made.
|
||||
return {
|
||||
"response": f"Note created: \"{title or '(untitled)'}\" (id: {note.id[:8]})",
|
||||
"response": f"{'Reminder' if due_iso else 'Note'} created: \"{title or '(untitled)'}\" (id: {note.id[:8]})",
|
||||
"note_id": note.id,
|
||||
"note_title": title or "",
|
||||
"open_url": f"/#open=notes¬e={note.id}",
|
||||
@@ -246,7 +297,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return {"response": f"Item '{items[index].get('text', '')}' marked {mark}", "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}. Use list/add/update/delete/toggle_item", "exit_code": 1}
|
||||
return {"error": f"Unknown action: {action}. Use list/search/view/add/update/delete/toggle_item", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_notes error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
+2
-2
@@ -34,8 +34,8 @@ async def do_search_chats(query: str, limit: int = 20, owner: str | None = None)
|
||||
|
||||
lines = [f"Found {len(seen_sessions)} session(s) matching \"{query}\":\n"]
|
||||
for sid, result in seen_sessions.items():
|
||||
lines.append(f"- **{result.session_name}** (#{sid})")
|
||||
lines.append(f" Link: [Open chat](#{sid})")
|
||||
lines.append(f"- [**{result.session_name}**](#session-{sid})")
|
||||
lines.append(f" Open: [Open chat](#session-{sid})")
|
||||
lines.append(f" Match ({result.role}): {result.content_snippet}")
|
||||
if result.context_before:
|
||||
before = result.context_before[-1]
|
||||
|
||||
@@ -538,6 +538,11 @@ _APP_API_BLOCKLIST_METHOD_PATH = (
|
||||
# sidebar surfaces the session. Raw start works but the agent
|
||||
# fumbles the payload + the session doesn't reliably show up.
|
||||
("POST", "/api/research/start"),
|
||||
# Use web_search — the HTTP search route is UI-shaped and generic
|
||||
# app_api calls can return empty/poorly formatted results compared with the
|
||||
# named tool's source-aware output.
|
||||
("GET", "/api/search"),
|
||||
("POST", "/api/search"),
|
||||
# Use the named tools — they handle owner attribution, natural-
|
||||
# language due_date parsing, timezone, dedup, and tag/category
|
||||
# normalization. Hitting the raw endpoint via app_api saves a
|
||||
@@ -653,6 +658,8 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return {"error": "Don't POST /api/model/serve directly — use the `serve_model` or `serve_preset` tool (handles host resolution, env_prefix, and cookbook tracking).", "exit_code": 1}
|
||||
if "/api/research/start" in path:
|
||||
return {"error": "Don't POST /api/research/start directly — use the `trigger_research` tool (it surfaces the session in the Deep Research sidebar).", "exit_code": 1}
|
||||
if "/api/search" in path:
|
||||
return {"error": "Don't hit /api/search via app_api — use the `web_search` tool for online lookups, or `web_fetch` for a specific URL.", "exit_code": 1}
|
||||
if "/api/notes" in path:
|
||||
return {"error": "Don't hit /api/notes via app_api — use the `manage_notes` tool. It accepts natural-language due_date ('11pm today', 'tomorrow at 9am'), fires reminders from the due_date itself (no separate calendar event), and uses the caller's timezone. The raw endpoint requires ISO-UTC + a separate calendar event, both of which the agent tends to get wrong.", "exit_code": 1}
|
||||
if "/api/calendar/events" in path:
|
||||
|
||||
+152
-5
@@ -7,10 +7,12 @@ import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpcore
|
||||
import httpx
|
||||
|
||||
from src.database import SessionLocal, Webhook
|
||||
@@ -125,6 +127,128 @@ def validate_webhook_url(url: str) -> str:
|
||||
return url
|
||||
|
||||
|
||||
def _validated_public_ips(url: str) -> list:
|
||||
"""Resolve *url*'s host and return its IPs, raising ValueError if any is
|
||||
private/internal.
|
||||
|
||||
``validate_webhook_url`` resolves the host to decide accept/reject, but the
|
||||
subsequent ``httpx`` connect re-resolves independently — so a DNS record
|
||||
that flips between the two lookups (rebinding) can slip an internal IP past
|
||||
the check. Callers pin the delivery connection to the IP this function
|
||||
returns, closing that TOCTOU. Fail closed: an unresolvable or partly-private
|
||||
result raises rather than returning a usable IP.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
hostname = (parsed.hostname or "").strip()
|
||||
if not hostname:
|
||||
raise ValueError("URL must have a hostname")
|
||||
try:
|
||||
literal = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
literal = None
|
||||
if literal is not None:
|
||||
if _ip_is_private(literal):
|
||||
raise ValueError("URL must not point to private/internal addresses")
|
||||
return [literal]
|
||||
addrs = _resolve_hostname_ips(hostname)
|
||||
if not addrs or any(_ip_is_private(a) for a in addrs):
|
||||
raise ValueError("URL must not point to private/internal addresses")
|
||||
return addrs
|
||||
|
||||
|
||||
# httpcore raises its own exception hierarchy; map the ones a simple POST can
|
||||
# surface back to their httpx equivalents so callers' `except httpx.*` blocks
|
||||
# (and sanitize_error) behave exactly as they did with the default transport.
|
||||
_HTTPCORE_TO_HTTPX_EXC = {
|
||||
httpcore.ConnectError: httpx.ConnectError,
|
||||
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||
httpcore.NetworkError: httpx.NetworkError,
|
||||
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||
httpcore.ProtocolError: httpx.ProtocolError,
|
||||
httpcore.ReadError: httpx.ReadError,
|
||||
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||
httpcore.TimeoutException: httpx.TimeoutException,
|
||||
httpcore.WriteError: httpx.WriteError,
|
||||
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||
}
|
||||
|
||||
|
||||
class _PinnedAsyncBackend(httpcore.AsyncNetworkBackend):
|
||||
"""Async network backend that routes every TCP connect to a fixed IP.
|
||||
|
||||
httpcore derives TLS SNI and the ``Host`` header from the request URL, not
|
||||
from the connect host, so pinning only the socket destination keeps
|
||||
certificate validation and vhost routing pointed at the original hostname.
|
||||
"""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress):
|
||||
self._ip = str(ip)
|
||||
self._real = httpcore.AnyIOBackend()
|
||||
|
||||
async def connect_tcp(self, host, port, timeout=None, local_address=None,
|
||||
socket_options=None):
|
||||
return await self._real.connect_tcp(
|
||||
self._ip, port, timeout, local_address, socket_options
|
||||
)
|
||||
|
||||
async def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||
return await self._real.connect_unix_socket(path, timeout, socket_options)
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
return await self._real.sleep(seconds)
|
||||
|
||||
|
||||
class _PinnedAsyncTransport(httpx.AsyncBaseTransport):
|
||||
"""httpx transport that pins the TCP connect to a pre-resolved public IP.
|
||||
|
||||
Uses only public ``httpcore`` / ``httpx`` APIs. The request URL is passed
|
||||
through unchanged (Host + SNI stay the original hostname); only the socket
|
||||
destination is pinned, closing the DNS-rebinding TOCTOU between the SSRF
|
||||
check and the connect. HTTP/1.1 only — webhook deliveries are small POSTs.
|
||||
"""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress):
|
||||
self._pool = httpcore.AsyncConnectionPool(
|
||||
ssl_context=ssl.create_default_context(),
|
||||
http1=True,
|
||||
http2=False,
|
||||
network_backend=_PinnedAsyncBackend(ip),
|
||||
)
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
core_req = httpcore.Request(
|
||||
method=request.method,
|
||||
url=httpcore.URL(
|
||||
scheme=request.url.raw_scheme,
|
||||
host=request.url.raw_host,
|
||||
port=request.url.port,
|
||||
target=request.url.raw_path,
|
||||
),
|
||||
headers=request.headers.raw,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
try:
|
||||
core_resp = await self._pool.handle_async_request(core_req)
|
||||
content = b"".join([chunk async for chunk in core_resp.aiter_stream()])
|
||||
await core_resp.aclose()
|
||||
except Exception as exc:
|
||||
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||
if mapped is not None:
|
||||
raise mapped(str(exc)) from exc
|
||||
raise
|
||||
return httpx.Response(
|
||||
status_code=core_resp.status,
|
||||
headers=core_resp.headers,
|
||||
content=content,
|
||||
extensions=core_resp.extensions,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._pool.aclose()
|
||||
|
||||
|
||||
def validate_events(events_str: str) -> str:
|
||||
"""Validate comma-separated event names. Returns cleaned string."""
|
||||
events = [e.strip() for e in events_str.split(",") if e.strip()]
|
||||
@@ -198,8 +322,11 @@ def sanitize_error(error: str, max_len: int = 200) -> str:
|
||||
|
||||
class WebhookManager:
|
||||
def __init__(self, api_key_manager=None):
|
||||
# Disable redirects to prevent SSRF via redirect chains
|
||||
self._client = httpx.AsyncClient(timeout=10, follow_redirects=False)
|
||||
# No shared client: each delivery builds a short-lived client whose
|
||||
# transport is pinned to the SSRF-approved IP (see _deliver /
|
||||
# _send_request), so a single reusable client can't be pointed at
|
||||
# different pinned hosts. Redirects stay disabled on every delivery
|
||||
# client to prevent SSRF via redirect chains.
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._api_key_manager = api_key_manager
|
||||
# Strong references to in-flight fire-and-forget tasks. asyncio only
|
||||
@@ -262,11 +389,28 @@ class WebhookManager:
|
||||
decrypted = self._decrypt_secret(encrypted_secret)
|
||||
await self._deliver(webhook_id, url, decrypted, "webhook.test", {"message": "Test ping from Odysseus"})
|
||||
|
||||
async def _send_request(self, url: str, body: str, headers: dict,
|
||||
ip: ipaddress._BaseAddress) -> httpx.Response:
|
||||
"""POST *body* to *url* with the TCP connect pinned to *ip*.
|
||||
|
||||
Overridable seam: tests replace this to avoid real sockets. Redirects
|
||||
are disabled so a 3xx can't bounce the delivery to another host.
|
||||
"""
|
||||
transport = _PinnedAsyncTransport(ip)
|
||||
async with httpx.AsyncClient(
|
||||
timeout=10, follow_redirects=False, transport=transport,
|
||||
) as client:
|
||||
return await client.post(url, content=body, headers=headers)
|
||||
|
||||
async def _deliver(self, webhook_id: str, url: str, secret: Optional[str], event: str, payload: dict):
|
||||
"""Internal delivery. Never call directly from outside this class (use deliver_test)."""
|
||||
# Re-validate URL at delivery time in case DB was tampered with
|
||||
# Re-validate URL at delivery time in case DB was tampered with, and
|
||||
# capture the exact IPs that passed the check so the connect can be
|
||||
# pinned to one of them (closes the DNS-rebinding TOCTOU: the check
|
||||
# below and the socket connect no longer resolve independently).
|
||||
try:
|
||||
validate_webhook_url(url)
|
||||
pinned_ips = _validated_public_ips(url)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Webhook {webhook_id} has invalid URL, skipping: {e}")
|
||||
return
|
||||
@@ -283,7 +427,7 @@ class WebhookManager:
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
resp = await self._client.post(url, content=body, headers=headers)
|
||||
resp = await self._send_request(url, body, headers, pinned_ips[0])
|
||||
db.query(Webhook).filter(Webhook.id == webhook_id).update({
|
||||
"last_triggered_at": _utcnow(),
|
||||
"last_status_code": resp.status_code,
|
||||
@@ -305,4 +449,7 @@ class WebhookManager:
|
||||
db.close()
|
||||
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
# Delivery clients are per-request and closed via their async context
|
||||
# manager, so there is no long-lived client to tear down here. Kept for
|
||||
# API compatibility with callers (e.g. app shutdown).
|
||||
return None
|
||||
|
||||
+256
-75
@@ -53,6 +53,73 @@ window.uiModule = uiModule;
|
||||
window.adminModule = adminModule;
|
||||
window.cookbookModule = cookbookModule;
|
||||
|
||||
function _isMobileChatInput() {
|
||||
return window.innerWidth <= 768;
|
||||
}
|
||||
|
||||
function _submitChatFormDirect(form) {
|
||||
if (!form) return;
|
||||
if (form.requestSubmit) form.requestSubmit();
|
||||
else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
function _isForegroundChatBusy() {
|
||||
const sendBtn = document.querySelector('.send-btn');
|
||||
return !!window.__odysseusChatBusy
|
||||
|| Date.now() < (window.__odysseusChatBusyUntil || 0)
|
||||
|| !!document.querySelector('.send-btn[data-mode="streaming"], .send-btn.send-pending')
|
||||
|| (sendBtn && (sendBtn.title || '').toLowerCase().includes('stop'));
|
||||
}
|
||||
|
||||
function _shouldQueueFromMobileEnter(e, input) {
|
||||
return e.key === 'Enter'
|
||||
&& !e.shiftKey
|
||||
&& !e.ctrlKey
|
||||
&& !e.metaKey
|
||||
&& !e.altKey
|
||||
&& !e.isComposing
|
||||
&& _isMobileChatInput()
|
||||
&& _isForegroundChatBusy()
|
||||
&& !!(input && input.value && input.value.trim());
|
||||
}
|
||||
|
||||
function _shouldQueueFromMobileLineBreak(input) {
|
||||
return _isMobileChatInput()
|
||||
&& _isForegroundChatBusy()
|
||||
&& !!(input && input.value && input.value.trim());
|
||||
}
|
||||
|
||||
function _isLineBreakInputEvent(e) {
|
||||
return e
|
||||
&& (e.inputType === 'insertLineBreak'
|
||||
|| e.inputType === 'insertParagraph'
|
||||
|| e.data === '\n');
|
||||
}
|
||||
|
||||
function _submitMobileQueuedInput(input) {
|
||||
if (!input || !_shouldQueueFromMobileLineBreak(input)) return false;
|
||||
const now = Date.now();
|
||||
const last = Number(input.dataset.mobileQueueSubmitAt || 0);
|
||||
if (now - last < 300) return true;
|
||||
input.dataset.mobileQueueSubmitAt = String(now);
|
||||
if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) {
|
||||
return true;
|
||||
}
|
||||
window.__odysseusQueueStreamingSubmit = now;
|
||||
const form = document.getElementById('chat-form');
|
||||
_submitChatFormDirect(form);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _syncMobileEnterKeyHint(input) {
|
||||
if (!input) return;
|
||||
input.setAttribute('enterkeyhint', (_isMobileChatInput() && _isForegroundChatBusy()) ? 'send' : 'enter');
|
||||
}
|
||||
|
||||
function _countLineBreaks(s) {
|
||||
return ((s || '').match(/\n/g) || []).length;
|
||||
}
|
||||
|
||||
function initForegroundActivityHeartbeat() {
|
||||
let lastSent = 0;
|
||||
const minGapMs = 12000;
|
||||
@@ -185,6 +252,50 @@ async function _createDirectChatFromPreferredModel() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function _hasUsableChatModel() {
|
||||
try {
|
||||
const pending = sessionModule?.getPendingChat?.();
|
||||
if (pending && pending.url && pending.modelId) return true;
|
||||
} catch (_) {}
|
||||
try {
|
||||
const current = sessionModule?.getSessions?.()
|
||||
?.find(s => s.id === sessionModule?.getCurrentSessionId?.());
|
||||
if (current && current.endpoint_url && current.model) return true;
|
||||
} catch (_) {}
|
||||
const dc = await _refreshDefaultChat();
|
||||
if (dc && dc.endpoint_url && dc.model) return true;
|
||||
try {
|
||||
const items = window.modelsModule?.getCachedItems?.() || [];
|
||||
if (items.some(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length))) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/models?background=false`, { credentials: 'same-origin' });
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return (data.items || []).some(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _syncWelcomeModelHint() {
|
||||
const tip = document.getElementById('welcome-tip');
|
||||
const sub = document.getElementById('welcome-sub');
|
||||
if (!tip && !sub) return;
|
||||
const hasModel = await _hasUsableChatModel();
|
||||
if (hasModel) {
|
||||
if (sub && !sub.dataset.researchOrigText) sub.textContent = 'New chat ready.';
|
||||
if (tip) tip.textContent = 'Pick a model if you want, or just type.';
|
||||
} else {
|
||||
if (sub && !sub.dataset.researchOrigText) {
|
||||
sub.innerHTML = 'Welcome, <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">type /setup</span> to get started.';
|
||||
}
|
||||
if (tip) tip.textContent = 'Add an AI endpoint from Settings in the sidebar, or paste an endpoint/API key into the chat.';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EVENT LISTENERS INITIALIZATION
|
||||
// ============================================
|
||||
@@ -195,9 +306,9 @@ function initializeEventListeners() {
|
||||
// File attachments (inside overflow menu)
|
||||
const _overflowAttach = el('overflow-attach-btn');
|
||||
if (_overflowAttach) _overflowAttach.addEventListener('click', fileHandlerModule.openPicker);
|
||||
el('file-input').addEventListener('change', (e)=>{
|
||||
for (const f of e.target.files) fileHandlerModule.addFiles([f]);
|
||||
fileHandlerModule.renderAttachStrip();
|
||||
el('file-input').addEventListener('change', async (e)=>{
|
||||
await fileHandlerModule.addFiles(Array.from(e.target.files || []));
|
||||
e.target.value = '';
|
||||
// Refocus textarea after file picker closes (mobile keyboard)
|
||||
const ta = el('message');
|
||||
if (ta) setTimeout(() => ta.focus(), 100);
|
||||
@@ -211,7 +322,7 @@ function initializeEventListeners() {
|
||||
if (item.kind === 'file'){
|
||||
const f = item.getAsFile();
|
||||
if (f) {
|
||||
fileHandlerModule.addFiles([f]);
|
||||
await fileHandlerModule.addFiles([f]);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -371,8 +482,10 @@ function initializeEventListeners() {
|
||||
}
|
||||
const body = child.querySelector('.body');
|
||||
// Prefer dataset.raw (original markdown) over innerText (rendered HTML as text)
|
||||
// to avoid extra newlines and formatting artifacts.
|
||||
const text = body ? (body.dataset.raw || body.innerText || body.textContent || '').trim() : '';
|
||||
// to avoid extra newlines and formatting artifacts. Raw text lives on
|
||||
// the outer .msg in the main renderer; keep body.dataset.raw as a legacy
|
||||
// fallback for older/reused render paths.
|
||||
const text = (child.dataset?.raw || body?.dataset?.raw || body?.innerText || body?.textContent || '').trim();
|
||||
if (text) parts.push(`${label}: ${text}`);
|
||||
} else if (child.classList?.contains('agent-thread')) {
|
||||
const lines = ['[Tool calls]'];
|
||||
@@ -3113,10 +3226,7 @@ function initializeEventListeners() {
|
||||
}, { passive: true });
|
||||
})();
|
||||
|
||||
// New session button on icon rail
|
||||
const railNewSession = el('rail-new-session');
|
||||
if (railNewSession) {
|
||||
railNewSession.addEventListener('click', async () => {
|
||||
async function _handleNewChatAction({ preferModel = true, focus = true } = {}) {
|
||||
if (!sessionModule) return;
|
||||
if (_closeCompareIfActive()) return;
|
||||
_deactivateIncognito();
|
||||
@@ -3125,18 +3235,24 @@ function initializeEventListeners() {
|
||||
// Clear research mode if active
|
||||
const _resChk = el('research-toggle');
|
||||
if (_resChk && _resChk.checked) _syncResearchIndicator(false);
|
||||
if (await _createDirectChatFromPreferredModel()) return;
|
||||
if (preferModel && await _createDirectChatFromPreferredModel()) return;
|
||||
// No models at all — show welcome screen
|
||||
sessionModule.setCurrentSessionId(null);
|
||||
if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
|
||||
_startFreshChat();
|
||||
const docBtn3 = el('overflow-doc-btn');
|
||||
if (docBtn3) docBtn3.classList.remove('active', 'has-docs');
|
||||
const box = el('chat-history');
|
||||
if (box) box.innerHTML = '';
|
||||
if (chatModule && chatModule.showWelcomeScreen) {
|
||||
chatModule.showWelcomeScreen();
|
||||
}
|
||||
document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
|
||||
if (focus) {
|
||||
const input = el('message');
|
||||
if (input) { try { input.focus(); } catch (_) {} }
|
||||
}
|
||||
}
|
||||
|
||||
// New session button on icon rail
|
||||
const railNewSession = el('rail-new-session');
|
||||
if (railNewSession) {
|
||||
railNewSession.addEventListener('click', async (e) => {
|
||||
if (e) { e.preventDefault(); e.stopPropagation(); }
|
||||
await _handleNewChatAction();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3163,31 +3279,17 @@ function initializeEventListeners() {
|
||||
// Logo click → new chat (same logic as rail new-session button)
|
||||
const brandBtn = el('sidebar-brand-btn');
|
||||
if (brandBtn) {
|
||||
brandBtn.addEventListener('click', async () => {
|
||||
if (!sessionModule) return;
|
||||
if (_closeCompareIfActive()) return;
|
||||
_deactivateIncognito();
|
||||
if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
|
||||
// Clear research toggle when starting a fresh chat (not via research button)
|
||||
_syncResearchIndicator(false);
|
||||
if (await _createDirectChatFromPreferredModel()) return;
|
||||
// No models at all — show welcome screen
|
||||
sessionModule.setCurrentSessionId(null);
|
||||
if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
|
||||
const docBtn2 = el('overflow-doc-btn');
|
||||
if (docBtn2) docBtn2.classList.remove('active', 'has-docs');
|
||||
const box = el('chat-history');
|
||||
if (box) box.innerHTML = '';
|
||||
if (chatModule && chatModule.showWelcomeScreen) chatModule.showWelcomeScreen();
|
||||
document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
|
||||
brandBtn.addEventListener('click', async (e) => {
|
||||
if (e) { e.preventDefault(); e.stopPropagation(); }
|
||||
await _handleNewChatAction();
|
||||
});
|
||||
}
|
||||
|
||||
const sidebarNewChatBtn = el('sidebar-new-chat-btn');
|
||||
if (sidebarNewChatBtn) {
|
||||
sidebarNewChatBtn.addEventListener('click', () => {
|
||||
const brandBtn = el('sidebar-brand-btn');
|
||||
if (brandBtn) brandBtn.click();
|
||||
sidebarNewChatBtn.addEventListener('click', async (e) => {
|
||||
if (e) { e.preventDefault(); e.stopImmediatePropagation(); }
|
||||
await _handleNewChatAction();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3226,17 +3328,38 @@ function initializeEventListeners() {
|
||||
// Textarea auto-resize
|
||||
const textarea = el('message');
|
||||
if (textarea) {
|
||||
_syncMobileEnterKeyHint(textarea);
|
||||
window.addEventListener('odysseus:chat-busy-change', () => _syncMobileEnterKeyHint(textarea));
|
||||
uiModule.autoResize(textarea);
|
||||
textarea.addEventListener('input', () => {
|
||||
let previousTextareaValue = textarea.value || '';
|
||||
textarea.addEventListener('beforeinput', (e) => {
|
||||
if (_isLineBreakInputEvent(e) && _shouldQueueFromMobileLineBreak(textarea)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_submitMobileQueuedInput(textarea);
|
||||
}
|
||||
});
|
||||
textarea.addEventListener('input', (e) => {
|
||||
const currentValue = textarea.value || '';
|
||||
const insertedLineBreak = _isLineBreakInputEvent(e)
|
||||
|| _countLineBreaks(currentValue) > _countLineBreaks(previousTextareaValue);
|
||||
if (insertedLineBreak && _shouldQueueFromMobileLineBreak(textarea)) {
|
||||
textarea.value = currentValue.replace(/\n+$/g, '');
|
||||
previousTextareaValue = textarea.value || '';
|
||||
_submitMobileQueuedInput(textarea);
|
||||
return;
|
||||
}
|
||||
previousTextareaValue = currentValue;
|
||||
uiModule.autoResize(textarea);
|
||||
_syncMobileEnterKeyHint(textarea);
|
||||
});
|
||||
textarea.addEventListener('paste', () => {
|
||||
setTimeout(() => uiModule.autoResize(textarea), 1);
|
||||
});
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
const isMobile = window.innerWidth <= 768
|
||||
const isMobile = _isMobileChatInput();
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
|
||||
if (_shouldQueueFromMobileEnter(e, textarea) || (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile)) {
|
||||
// If ghost autocomplete is active, accept the suggestion instead of submitting
|
||||
if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) {
|
||||
e.preventDefault();
|
||||
@@ -3249,8 +3372,13 @@ function initializeEventListeners() {
|
||||
// Check if already submitting before triggering form submission
|
||||
const form = el('chat-form');
|
||||
if (form) {
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.click();
|
||||
if (_isForegroundChatBusy() && textarea.value && textarea.value.trim()) {
|
||||
if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) {
|
||||
return;
|
||||
}
|
||||
window.__odysseusQueueStreamingSubmit = Date.now();
|
||||
}
|
||||
_submitChatFormDirect(form);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3429,7 +3557,7 @@ function initializeEventListeners() {
|
||||
// Now submit the form (the /new command handler will process it)
|
||||
setTimeout(() => {
|
||||
const form = el('chat-form');
|
||||
if (form) form.querySelector('button[type="submit"]').click();
|
||||
_submitChatFormDirect(form);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
@@ -3685,10 +3813,31 @@ function startOdysseusApp() {
|
||||
return fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount() > 0;
|
||||
}
|
||||
|
||||
function _updateStreamingSubmitButton() {
|
||||
if (!sendBtn || sendBtn.dataset.mode !== 'streaming') return false;
|
||||
const hasText = messageInput && messageInput.value.trim().length > 0;
|
||||
const nextPhase = hasText ? 'queue' : 'processing';
|
||||
if (sendBtn.dataset.phase === nextPhase) return true;
|
||||
sendBtn.dataset.phase = nextPhase;
|
||||
sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded', 'anim-spin', 'anim-launch', 'anim-land');
|
||||
if (hasText) {
|
||||
sendBtn.innerHTML = _sendIcon;
|
||||
sendBtn.title = 'Queue message';
|
||||
} else {
|
||||
sendBtn.innerHTML = _stopIcon;
|
||||
sendBtn.title = 'Stop generation';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _updateSendBtnIcon() {
|
||||
if (!sendBtn) return;
|
||||
// Don't override if streaming (stop button) or recording
|
||||
if (sendBtn.dataset.mode === 'streaming' || sendBtn.dataset.mode === 'recording') return;
|
||||
if (sendBtn.dataset.mode === 'streaming') {
|
||||
_updateStreamingSubmitButton();
|
||||
return;
|
||||
}
|
||||
// Don't override if recording
|
||||
if (sendBtn.dataset.mode === 'recording') return;
|
||||
const prevMode = sendBtn.dataset.mode || '';
|
||||
const hasText = messageInput && messageInput.value.trim().length > 0;
|
||||
const hasFiles = _hasAttachments();
|
||||
@@ -3784,6 +3933,12 @@ function startOdysseusApp() {
|
||||
const hasText = messageInput && messageInput.value.trim().length > 0;
|
||||
const hasFiles = _hasAttachments();
|
||||
|
||||
if (sendBtn.dataset.mode === 'streaming') {
|
||||
if (hasText) window.__odysseusQueueStreamingSubmit = Date.now();
|
||||
handleSubmit(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// New chat mode — empty input, no attachments, no STT
|
||||
if (!hasText && !hasFiles && sendBtn.dataset.mode === 'newchat') {
|
||||
if (sessionModule) {
|
||||
@@ -3823,9 +3978,10 @@ function startOdysseusApp() {
|
||||
// Enter to send (shift+enter for newline), or new chat when empty
|
||||
if (messageInput) {
|
||||
messageInput.addEventListener('keydown', (e) => {
|
||||
const isMobile = window.innerWidth <= 768
|
||||
if (e.defaultPrevented) return;
|
||||
const isMobile = _isMobileChatInput();
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
|
||||
if (_shouldQueueFromMobileEnter(e, messageInput) || (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile)) {
|
||||
e.preventDefault();
|
||||
// Flush the debounced icon update so dataset.mode reflects the current
|
||||
// text state. Without this, a fast type-and-Enter would still see the
|
||||
@@ -3836,7 +3992,13 @@ function startOdysseusApp() {
|
||||
if (railNew) railNew.click();
|
||||
return;
|
||||
}
|
||||
handleSubmit(e);
|
||||
if (_isForegroundChatBusy() && messageInput.value && messageInput.value.trim()) {
|
||||
if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) {
|
||||
return;
|
||||
}
|
||||
window.__odysseusQueueStreamingSubmit = Date.now();
|
||||
}
|
||||
_submitChatFormDirect(document.getElementById('chat-form'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3855,7 +4017,11 @@ function startOdysseusApp() {
|
||||
_syncModelPickerAutohide();
|
||||
messageInput.addEventListener('input', () => {
|
||||
_syncModelPickerAutohide();
|
||||
_debouncedUpdateIcon();
|
||||
if (sendBtn && sendBtn.dataset.mode === 'streaming') {
|
||||
_updateSendBtnIcon();
|
||||
} else {
|
||||
_debouncedUpdateIcon();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
@@ -3910,14 +4076,13 @@ function startOdysseusApp() {
|
||||
_showDropHighlight();
|
||||
});
|
||||
|
||||
chatContainer.addEventListener('drop', (e) => {
|
||||
chatContainer.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_hideDropHighlight();
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
fileHandlerModule.addFiles(files);
|
||||
fileHandlerModule.renderAttachStrip();
|
||||
await fileHandlerModule.addFiles(files);
|
||||
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
|
||||
});
|
||||
|
||||
@@ -3934,12 +4099,13 @@ function startOdysseusApp() {
|
||||
attachStrip.style.borderRadius = '4px';
|
||||
});
|
||||
|
||||
attachStrip.addEventListener('drop', (e) => {
|
||||
attachStrip.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
attachStrip.style.backgroundColor = '';
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
await fileHandlerModule.addFiles(files);
|
||||
|
||||
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
|
||||
|
||||
@@ -4007,14 +4173,13 @@ function startOdysseusApp() {
|
||||
if (_compareActive() && !e.relatedTarget) _hideCmpShield();
|
||||
}, true);
|
||||
window.addEventListener('dragend', _hideCmpShield, true);
|
||||
window.addEventListener('drop', (e) => {
|
||||
window.addEventListener('drop', async (e) => {
|
||||
if (!_isFileDrag(e) || !_compareActive()) return;
|
||||
e.preventDefault();
|
||||
_hideCmpShield();
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (!files.length) return;
|
||||
fileHandlerModule.addFiles(files);
|
||||
fileHandlerModule.renderAttachStrip();
|
||||
await fileHandlerModule.addFiles(files);
|
||||
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to attach`);
|
||||
}, true);
|
||||
|
||||
@@ -4049,24 +4214,40 @@ function startOdysseusApp() {
|
||||
console.error('Session module not loaded!');
|
||||
}
|
||||
|
||||
// Non-critical: load in parallel, resolve silently
|
||||
modelsModule.refreshModels(false).then(() => {
|
||||
try { sessionModule.updateModelPicker(); } catch (_) {}
|
||||
const modelsBox = document.getElementById('models');
|
||||
const hasModels = modelsBox && modelsBox.querySelector('.models-row');
|
||||
if (!hasModels) {
|
||||
const tip = document.getElementById('welcome-tip');
|
||||
if (tip) tip.textContent = 'Add an AI endpoint from Settings in the sidebar, or paste an endpoint/API key into the chat.';
|
||||
}
|
||||
}).catch(() => {});
|
||||
modelsModule.refreshProviders();
|
||||
ragModule.loadPersonalDocs();
|
||||
memoryModule.loadMemories(); // Ensure memories are loaded on page load
|
||||
|
||||
// Ensure the memory list is rendered after loading
|
||||
setTimeout(async () => {
|
||||
await memoryModule.loadMemories();
|
||||
}, 1000);
|
||||
const runNonCriticalStartup = (fn, delay = 4000) => {
|
||||
let tries = 0;
|
||||
const run = () => {
|
||||
const busy = !!window.__odysseusChatBusy
|
||||
|| Date.now() < (window.__odysseusChatBusyUntil || 0)
|
||||
|| !!document.querySelector('.send-btn[data-mode="streaming"], .send-btn.send-pending');
|
||||
if (busy && tries < 12) {
|
||||
tries += 1;
|
||||
setTimeout(run, 2500);
|
||||
return;
|
||||
}
|
||||
try { fn(); } catch (e) { console.warn('non-critical startup task failed:', e); }
|
||||
};
|
||||
setTimeout(() => {
|
||||
if ('requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(run, { timeout: 5000 });
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
// Non-critical startup work must not compete with first paint, chat send, or
|
||||
// chat switching. Panels load their own data when opened; these are only warmups.
|
||||
_syncWelcomeModelHint().catch(() => {});
|
||||
runNonCriticalStartup(() => {
|
||||
modelsModule.refreshModels(false).then(() => {
|
||||
try { sessionModule.updateModelPicker(); } catch (_) {}
|
||||
_syncWelcomeModelHint().catch(() => {});
|
||||
}).catch(() => {});
|
||||
}, 3500);
|
||||
runNonCriticalStartup(() => modelsModule.refreshProviders(), 6500);
|
||||
runNonCriticalStartup(() => ragModule.loadPersonalDocs(), 9000);
|
||||
runNonCriticalStartup(() => memoryModule.loadMemories(), 12000);
|
||||
|
||||
// Ensure proper initial state
|
||||
voiceRecorderModule.init();
|
||||
|
||||
+5
-33
@@ -1356,7 +1356,7 @@
|
||||
<div class="settings-sidebar">
|
||||
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
|
||||
<button class="settings-nav-item active" data-settings-tab="services">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
<span>Add Models</span>
|
||||
</button>
|
||||
<button class="settings-nav-item" data-settings-tab="added-models">
|
||||
@@ -1979,34 +1979,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>Translation</h2>
|
||||
<div class="settings-row" style="align-items:center;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="settings-label" style="margin-bottom:3px;">Auto translate</div>
|
||||
<div class="admin-toggle-sub" style="margin:0;">When an opened email appears to be in another language, prepare a translated view.</div>
|
||||
</div>
|
||||
<label class="admin-switch"><input type="checkbox" id="set-email-auto-translate"><span class="admin-slider"></span></label>
|
||||
</div>
|
||||
<div class="settings-row" style="align-items:center;margin-top:8px;">
|
||||
<label class="settings-label" for="set-email-translate-language">Translate to</label>
|
||||
<input id="set-email-translate-language" class="settings-select" list="set-email-translate-language-list" placeholder="English, Swedish, Japanese..." style="max-width:220px;">
|
||||
<datalist id="set-email-translate-language-list">
|
||||
<option value="English"></option>
|
||||
<option value="Swedish"></option>
|
||||
<option value="Norwegian"></option>
|
||||
<option value="Danish"></option>
|
||||
<option value="Japanese"></option>
|
||||
<option value="Spanish"></option>
|
||||
<option value="French"></option>
|
||||
<option value="German"></option>
|
||||
<option value="British English"></option>
|
||||
<option value="Plain English"></option>
|
||||
</datalist>
|
||||
<span id="set-email-translate-msg" style="font-size:11px;margin-left:auto;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══ REMINDERS TAB ═══ -->
|
||||
@@ -2122,7 +2094,7 @@
|
||||
|
||||
<!-- ── Local card ─────────────────────────────────────────── -->
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>Add Local Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 5v14"/><path d="M5 12h14"/></svg>Add Local Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
|
||||
@@ -2154,7 +2126,7 @@
|
||||
<input id="adm-epLocalUrl" type="text" placeholder="Paste endpoint URL, e.g. http://localhost:11434/v1" style="flex:1;min-width:0;border-top-left-radius:0;border-bottom-left-radius:0;">
|
||||
</div>
|
||||
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;flex-shrink:0;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-model-form-row" id="adm-epLocalApiKey-row" style="display:none;">
|
||||
@@ -2167,7 +2139,7 @@
|
||||
|
||||
<!-- ── API card ───────────────────────────────────────────── -->
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>Add API Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><circle cx="11" cy="12" r="8"/><path d="M19 5v6"/><path d="M16 8h6"/><path d="M7 12h8"/></svg>Add API Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
|
||||
@@ -2236,7 +2208,7 @@
|
||||
<input id="adm-epApiKey" type="password" placeholder="API key, e.g. sk-proj-AbCdEf…" autocomplete="off" style="flex:1;padding-left:28px;height:32px;box-sizing:border-box;">
|
||||
</div>
|
||||
<button class="admin-btn-add" id="adm-epAddBtn" style="height:32px;min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;flex-shrink:0;box-sizing:border-box;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>Add
|
||||
</button>
|
||||
</div>
|
||||
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
|
||||
|
||||
+1
-1
@@ -471,7 +471,7 @@ async function loadEndpoints() {
|
||||
const listLegacy = el('adm-epList');
|
||||
// Refresh model picker so new endpoints show up in chat
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels();
|
||||
window.modelsModule.refreshModels(true);
|
||||
setTimeout(() => {
|
||||
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
||||
window.sessionModule.updateModelPicker();
|
||||
|
||||
+279
-63
@@ -48,9 +48,53 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
try {
|
||||
window.__odysseusChatBusy = !!active;
|
||||
window.__odysseusChatBusyUntil = active ? Date.now() + 120000 : Date.now() + 1200;
|
||||
window.dispatchEvent(new CustomEvent('odysseus:chat-busy-change', { detail: { active: !!active } }));
|
||||
} catch (_) {}
|
||||
}
|
||||
let _pendingContinue = null; // Stores the stopped AI element to merge with new response
|
||||
function _createChatSendPerf() {
|
||||
const started = (performance && performance.now) ? performance.now() : Date.now();
|
||||
let last = started;
|
||||
let reported = false;
|
||||
const stages = [];
|
||||
const now = () => (performance && performance.now) ? performance.now() : Date.now();
|
||||
return {
|
||||
mark(name) {
|
||||
const t = now();
|
||||
stages.push({ name, delta_ms: Math.round(t - last), at_ms: Math.round(t - started) });
|
||||
last = t;
|
||||
},
|
||||
report(extra) {
|
||||
if (reported) return;
|
||||
const total = Math.round(now() - started);
|
||||
const slowStage = stages.some(s => (s.delta_ms || 0) >= 1500);
|
||||
if (total < 1500 && !slowStage) return;
|
||||
reported = true;
|
||||
const payload = JSON.stringify({
|
||||
type: 'chat_send',
|
||||
total_ms: total,
|
||||
stages,
|
||||
extra: extra || '',
|
||||
session: sessionModule && sessionModule.getCurrentSessionId ? sessionModule.getCurrentSessionId() : '',
|
||||
});
|
||||
try {
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(`${API_BASE}/api/client-perf`, new Blob([payload], { type: 'application/json' }));
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
fetch(`${API_BASE}/api/client-perf`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {});
|
||||
} catch (_) {}
|
||||
}
|
||||
};
|
||||
}
|
||||
// ── Auto-recovery: when a turn's stream silently dies (connection drop) or
|
||||
// goes quiet while the connection is alive, re-engage the model with a
|
||||
// completion handshake instead of leaving it hung. Capped so it can't loop.
|
||||
@@ -120,6 +164,26 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
return final && !visible ? 'Done.' : visible;
|
||||
}
|
||||
|
||||
function _stripIncompleteRawToolJsonForChat(text) {
|
||||
const s = String(text || '');
|
||||
const starts = ['[{"function"', '[\n{"function"', '{"function"'];
|
||||
let idx = -1;
|
||||
for (const marker of starts) idx = Math.max(idx, s.lastIndexOf(marker));
|
||||
if (idx < 0) return s;
|
||||
const tail = s.slice(idx);
|
||||
// Complete raw OpenAI-style function blobs are removed by stripToolBlocks.
|
||||
// While the stream is still mid-JSON, hide the tail so it never flashes in
|
||||
// the chat bubble as prose.
|
||||
if (!/"type"\s*:\s*"function"/.test(tail) || !/\}\s*\]?\s*(?:<\/?\|(?:assistant|assistan|user|system|tool)\|>?)?\s*$/i.test(tail)) {
|
||||
return s.slice(0, idx);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function _streamDisplayText(text, opts = {}) {
|
||||
return stripToolBlocks(_stripIncompleteRawToolJsonForChat(_stripDocumentFenceForChat(text, opts)));
|
||||
}
|
||||
|
||||
function _showDocumentWritingStatus(contentEl) {
|
||||
const msg = contentEl && contentEl.closest ? contentEl.closest('.msg') : null;
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
@@ -204,6 +268,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
let _streamSessionId = null; // Session ID for the currently active reader loop
|
||||
let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
|
||||
let _webLockRelease = null; // Function to release the Web Lock held during streaming
|
||||
let _staleStreamProbeInFlight = false;
|
||||
const STALE_LOCAL_STREAM_MS = 15000;
|
||||
|
||||
/** Check if an SSE reader is still actively connected for a session. */
|
||||
function hasActiveStream(sessionId) {
|
||||
@@ -326,7 +392,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
// arrow out for the stop icon — otherwise the swap happens mid-flight
|
||||
// and the user sees nothing fly out.
|
||||
setTimeout(() => {
|
||||
submitBtn.innerHTML = _stopSvg;
|
||||
if (submitBtn.dataset.mode !== 'streaming') return;
|
||||
const msgInput = uiModule.el('message');
|
||||
const hasQueuedText = !!(msgInput && msgInput.value && msgInput.value.trim());
|
||||
submitBtn.innerHTML = hasQueuedText && icons ? icons.send : _stopSvg;
|
||||
submitBtn.dataset.phase = hasQueuedText ? 'queue' : 'processing';
|
||||
submitBtn.title = hasQueuedText ? 'Queue message' : 'Stop generation';
|
||||
submitBtn.classList.remove('anim-launch');
|
||||
void submitBtn.offsetWidth;
|
||||
submitBtn.classList.add('anim-land');
|
||||
@@ -471,6 +542,24 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
return true;
|
||||
}
|
||||
|
||||
export function queueStreamingComposerRequest() {
|
||||
if (!isStreaming) return false;
|
||||
const queuedInput = uiModule.el('message');
|
||||
const queuedText = (queuedInput && queuedInput.value || '').trim();
|
||||
if (!queuedText) return false;
|
||||
if (fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount()) {
|
||||
try { uiModule.showError && uiModule.showError('Finish the current response before queueing messages with attachments.'); } catch (_) {}
|
||||
return true;
|
||||
}
|
||||
if (_queueAgentRequest(queuedText)) {
|
||||
queuedInput.value = '';
|
||||
queuedInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
if (uiModule.autoResize) uiModule.autoResize(queuedInput);
|
||||
try { window._updateSendBtnIcon && window._updateSendBtnIcon(); } catch (_) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _drainQueuedAgentRequests() {
|
||||
if (isStreaming || _sendInFlight || !_queuedAgentRequests.length) return;
|
||||
if (_queuedDrainTimer) return;
|
||||
@@ -507,21 +596,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
return;
|
||||
}
|
||||
|
||||
// If currently streaming, a non-empty composer means "queue this next".
|
||||
// Empty composer keeps the existing Stop behavior.
|
||||
// If currently streaming, keyboard Enter can queue a non-empty composer.
|
||||
// Clicking the stop icon should still stop normally, even if text exists.
|
||||
if (isStreaming) {
|
||||
const queuedInput = uiModule.el('message');
|
||||
const queuedText = (queuedInput && queuedInput.value || '').trim();
|
||||
if (queuedText) {
|
||||
if (fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount()) {
|
||||
try { uiModule.showError && uiModule.showError('Finish the current response before queueing messages with attachments.'); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (_queueAgentRequest(queuedText)) {
|
||||
queuedInput.value = '';
|
||||
queuedInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
if (uiModule.autoResize) uiModule.autoResize(queuedInput);
|
||||
}
|
||||
const queueRequestedAt = Number(window.__odysseusQueueStreamingSubmit || 0);
|
||||
const shouldQueueStreamingSubmit = queueRequestedAt && Date.now() - queueRequestedAt < 1200;
|
||||
window.__odysseusQueueStreamingSubmit = 0;
|
||||
if (shouldQueueStreamingSubmit && queueStreamingComposerRequest()) {
|
||||
return;
|
||||
}
|
||||
if (fileHandlerModule.isUploading && fileHandlerModule.isUploading()) {
|
||||
@@ -652,6 +733,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
|
||||
// --- Send-path entry: block re-clicks between submit and stream start ---
|
||||
if (_sendInFlight) return;
|
||||
const _sendPerf = _createChatSendPerf();
|
||||
_sendInFlight = true;
|
||||
_setForegroundChatBusy(true);
|
||||
// Instant visual feedback so the user sees their click was accepted
|
||||
@@ -710,18 +792,34 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
|
||||
// Materialize pending session (deferred from model click) on first message
|
||||
if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
|
||||
_sendPerf.mark('pending_session_begin');
|
||||
const ok = await sessionModule.materializePendingSession();
|
||||
_sendPerf.mark('pending_session_done');
|
||||
if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
|
||||
}
|
||||
|
||||
if (!sessionModule.getCurrentSessionId()) {
|
||||
// Auto-create a session using default chat config. Always fetch fresh
|
||||
// so that a recent Settings change takes effect without a page reload.
|
||||
try {
|
||||
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
|
||||
if (pending && pending.url && pending.modelId) {
|
||||
const ok = await sessionModule.materializePendingSession();
|
||||
if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!sessionModule.getCurrentSessionId()) {
|
||||
// Auto-create a session using default chat config. Always fetch fresh
|
||||
// so that a recent Settings change takes effect without a page reload.
|
||||
try {
|
||||
let dc = null;
|
||||
try {
|
||||
_sendPerf.mark('default_chat_fetch_begin');
|
||||
const dcRes = await fetch('/api/default-chat');
|
||||
dc = await dcRes.json();
|
||||
_sendPerf.mark('default_chat_fetch_done');
|
||||
if (dc && dc.endpoint_url && dc.model) {
|
||||
try { window.__odysseusDefaultChat = dc; } catch (_) {}
|
||||
}
|
||||
@@ -729,8 +827,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
dc = (typeof window !== 'undefined' && window.__odysseusDefaultChat) || null;
|
||||
}
|
||||
if (dc.endpoint_url && dc.model) {
|
||||
_sendPerf.mark('direct_chat_create_begin');
|
||||
await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
|
||||
_sendPerf.mark('direct_chat_create_done');
|
||||
const ok = await sessionModule.materializePendingSession();
|
||||
_sendPerf.mark('direct_chat_materialize_done');
|
||||
if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
|
||||
} else {
|
||||
el('message').value = '';
|
||||
@@ -886,6 +987,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (!skipBubble) {
|
||||
_userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null);
|
||||
}
|
||||
_sendPerf.mark('user_bubble_visible');
|
||||
messageInput.value = '';
|
||||
messageInput.style.height = '';
|
||||
messageInput.dispatchEvent(new Event('input'));
|
||||
@@ -921,9 +1023,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
|
||||
let ids = [];
|
||||
try {
|
||||
_sendPerf.mark('upload_begin');
|
||||
ids = await fileHandlerModule.uploadPending({ sessionId: sessionModule.getCurrentSessionId() });
|
||||
_sendPerf.mark('upload_done');
|
||||
} catch(e) {
|
||||
console.error('upload failed', e);
|
||||
_sendPerf.mark('upload_failed');
|
||||
}
|
||||
if (_pendingAttachInfo && !ids.length && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) {
|
||||
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
|
||||
@@ -1021,11 +1126,18 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
let activeDocIdForSend = documentModule && typeof documentModule.getCurrentDocId === 'function'
|
||||
? documentModule.getCurrentDocId()
|
||||
: null;
|
||||
if (!activeDocIdForSend && activeEmailComposerCtx?.docId) {
|
||||
if (activeEmailComposerCtx?.docId) {
|
||||
activeDocIdForSend = activeEmailComposerCtx.docId;
|
||||
}
|
||||
if (documentModule && activeDocIdForSend) {
|
||||
try { await documentModule.saveDocument(); } catch(e) { console.warn('doc auto-save failed', e); }
|
||||
try {
|
||||
_sendPerf.mark('doc_save_begin');
|
||||
await documentModule.saveDocument();
|
||||
_sendPerf.mark('doc_save_done');
|
||||
} catch(e) {
|
||||
console.warn('doc auto-save failed', e);
|
||||
_sendPerf.mark('doc_save_failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Inject document selection context if present
|
||||
@@ -1057,7 +1169,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (ids.length) fd.append('attachments', JSON.stringify(ids));
|
||||
// Auto-save & send active doc ID so the backend sees latest content
|
||||
if (documentModule && activeDocIdForSend) {
|
||||
try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ }
|
||||
try {
|
||||
_sendPerf.mark('doc_silent_save_begin');
|
||||
await documentModule.saveDocument({ silent: true });
|
||||
_sendPerf.mark('doc_silent_save_done');
|
||||
} catch (_e) {
|
||||
_sendPerf.mark('doc_silent_save_failed');
|
||||
}
|
||||
fd.append('active_doc_id', activeDocIdForSend);
|
||||
}
|
||||
// Active email context — when an email reader is open, pass its
|
||||
@@ -1076,7 +1194,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (emCtx.account) fd.append('active_email_account', String(emCtx.account));
|
||||
}
|
||||
} catch (_e) { /* best-effort */ }
|
||||
// Web toggle: pre-search in Chat mode, tool permission in Agent mode
|
||||
// Web toggle: pre-search in Chat mode only. Agent mode should not
|
||||
// opportunistically hit SearXNG just because the chat search toggle is
|
||||
// on; explicit web/current-info requests are handled by the backend
|
||||
// intent gate.
|
||||
const toggleState = Storage.loadToggleState();
|
||||
let isAgentMode = (toggleState.mode || 'chat') === 'agent';
|
||||
const incognitoChk = el('incognito-toggle');
|
||||
@@ -1088,13 +1209,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
}
|
||||
fd.append('mode', isAgentMode ? 'agent' : 'chat');
|
||||
if (el('web-toggle').checked) {
|
||||
if (isAgentMode) {
|
||||
fd.append('allow_web_search', 'true');
|
||||
} else {
|
||||
if (!isAgentMode) {
|
||||
fd.append('use_web', 'true');
|
||||
}
|
||||
} else if (isAgentMode) {
|
||||
fd.append('allow_web_search', 'false');
|
||||
}
|
||||
if (isAgentMode) {
|
||||
fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false');
|
||||
}
|
||||
if (el('research-toggle').checked) {
|
||||
fd.append('use_research', 'true');
|
||||
@@ -1229,12 +1349,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; }
|
||||
catch { return ''; }
|
||||
})();
|
||||
_sendPerf.mark('chat_stream_post_begin');
|
||||
const res = await fetch(`${API_BASE}/api/chat_stream`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
headers: { 'X-Tz-Offset': String(_tzOffsetMin), 'X-Tz-Name': _tzName },
|
||||
signal: abortCtrl.signal
|
||||
});
|
||||
_sendPerf.mark('chat_stream_headers');
|
||||
_sendPerf.report('headers_received');
|
||||
|
||||
if (!res.ok) {
|
||||
clearResponseTimeout();
|
||||
@@ -1280,6 +1403,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (_chatLog) _chatLog.setAttribute('aria-busy', 'true');
|
||||
|
||||
const reader = res.body.getReader();
|
||||
_sendPerf.mark('reader_ready');
|
||||
_sendPerf.report('reader_ready');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let metrics = null;
|
||||
@@ -1322,6 +1447,32 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
}
|
||||
return contentDiv;
|
||||
}
|
||||
function _ensureVisibleRoundForDelta() {
|
||||
if (!roundHolder || roundHolder.style.display !== 'none') return;
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) {
|
||||
roundHolder.style.display = '';
|
||||
return;
|
||||
}
|
||||
const newWrap = document.createElement('div');
|
||||
newWrap.className = 'msg msg-ai msg-continuation streaming';
|
||||
const newRole = document.createElement('div');
|
||||
newRole.className = 'role';
|
||||
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
|
||||
const requested = holder?._requestedModel || metaS?.model || modelName;
|
||||
const actual = holder?._actualModel || requested;
|
||||
newRole.textContent = _modelRouteLabel(requested, actual) || '';
|
||||
_applyModelColor(newRole, actual);
|
||||
newWrap.appendChild(newRole);
|
||||
const newBody = document.createElement('div');
|
||||
newBody.className = 'body';
|
||||
newWrap.appendChild(newBody);
|
||||
box.appendChild(newWrap);
|
||||
if (lastToolThread && lastToolThread.isConnected) lastToolThread.classList.add('has-bottom');
|
||||
roundHolder = newWrap;
|
||||
roundText = '';
|
||||
roundFinalized = false;
|
||||
}
|
||||
const esc = uiModule.esc;
|
||||
// Remove thinking spinner helper
|
||||
_removeThinkingSpinner = () => {
|
||||
@@ -1445,7 +1596,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
|
||||
// Direct render helper for streaming text
|
||||
_renderStream = () => {
|
||||
let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText)));
|
||||
let dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText));
|
||||
const bodyEl = roundHolder.querySelector('.body');
|
||||
const contentEl = _ensureStreamLayout(bodyEl);
|
||||
|
||||
@@ -1700,24 +1851,25 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (!_thinkOpen) { _delta = '<think>' + _delta; _thinkOpen = true; }
|
||||
} else if (_thinkOpen) {
|
||||
_delta = '</think>' + _delta; _thinkOpen = false;
|
||||
}
|
||||
const wasEmpty = !accumulated;
|
||||
accumulated += _delta;
|
||||
roundText += _delta;
|
||||
currentAccumulated = accumulated; // Update global tracker
|
||||
// First token arrived — switch stop button from processing to streaming
|
||||
if (wasEmpty && submitBtn && !_isBg) {
|
||||
submitBtn.dataset.phase = 'receiving';
|
||||
}
|
||||
const wasEmpty = !accumulated;
|
||||
accumulated += _delta;
|
||||
currentAccumulated = accumulated; // Update global tracker
|
||||
// First token arrived — switch stop button from processing to streaming
|
||||
if (wasEmpty && submitBtn && !_isBg) {
|
||||
submitBtn.dataset.phase = 'receiving';
|
||||
}
|
||||
|
||||
// Update background map if running in background
|
||||
if (_isBg) {
|
||||
var bgEntry = _backgroundStreams.get(streamSessionId);
|
||||
if (bgEntry) bgEntry.accumulated = accumulated;
|
||||
continue; // Skip all DOM writes
|
||||
}
|
||||
if (bgEntry) bgEntry.accumulated = accumulated;
|
||||
continue; // Skip all DOM writes
|
||||
}
|
||||
_ensureVisibleRoundForDelta();
|
||||
roundText += _delta;
|
||||
|
||||
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
|
||||
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
|
||||
if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
|
||||
const fenceMarker = roundText.includes('```document\n') ? '```document\n' : (roundText.includes('```documen\n') ? '```documen\n' : '```create_document\n');
|
||||
const fenceIdx = roundText.indexOf(fenceMarker);
|
||||
@@ -1852,7 +2004,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
} else if (hasUnclosedThink && isThinking) {
|
||||
if (_liveThinkInner) {
|
||||
// Extract raw thinking text (strip known thinking wrappers and prefixes)
|
||||
var thinkText = markdownModule.normalizeThinkingMarkup(roundText)
|
||||
var thinkText = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText))
|
||||
.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
|
||||
.replace(/<\|channel>thought\s*\n?/gi, '')
|
||||
.replace(/<\|channel>response\s*\n?/gi, '')
|
||||
@@ -2201,23 +2353,6 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
_chatBox.appendChild(note);
|
||||
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
|
||||
}
|
||||
} else if (json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted') {
|
||||
// A loop guard ended the turn — surface why so it isn't mistaken
|
||||
// for a clean completion or a silent stall.
|
||||
const _chatBox = document.getElementById('chat-history');
|
||||
if (!_isBg && _chatBox) {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'stopped-indicator loop-guard-stop';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'rounds-exhausted-label';
|
||||
label.textContent = json.message ||
|
||||
(json.type === 'loop_breaker_triggered'
|
||||
? 'Stopped by the loop-breaker (no new progress).'
|
||||
: 'Stopped: announced an action but never called the tool.');
|
||||
note.appendChild(label);
|
||||
_chatBox.appendChild(note);
|
||||
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
|
||||
}
|
||||
} else if (json.type === 'model_actual') {
|
||||
if (!_isBg && holder) {
|
||||
holder._requestedModel = json.requested_model || holder._requestedModel || modelName;
|
||||
@@ -2302,6 +2437,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (!_isBg) {
|
||||
uiModule.showToast('Context compacted — older messages summarized');
|
||||
}
|
||||
} else if (json.type === 'context_trimmed') {
|
||||
if (!_isBg) {
|
||||
const d = json.data || {};
|
||||
const before = Number(d.messages_before || 0);
|
||||
const after = Number(d.messages_after || 0);
|
||||
const detail = before && after && before > after ? ` (${after}/${before} messages sent)` : '';
|
||||
uiModule.showToast(`Context trimmed for this model${detail}`);
|
||||
}
|
||||
} else if (json.type === 'metrics') {
|
||||
metrics = json.data;
|
||||
if (!_isBg && holder && metrics) {
|
||||
@@ -2348,7 +2491,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (!roundFinalized) {
|
||||
roundFinalized = true;
|
||||
if (spinner && spinner.element) spinner.destroy();
|
||||
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText)));
|
||||
const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText));
|
||||
if (dt.trim()) {
|
||||
var _body3 = roundHolder.querySelector('.body');
|
||||
var _contentEl3 = _ensureStreamLayout(_body3);
|
||||
@@ -2572,6 +2715,25 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (json.ui_event) {
|
||||
chatStream.handleUIControl(json);
|
||||
}
|
||||
// Native document tool calls can arrive as a completed
|
||||
// tool_output without the text-fence streaming path. Open the
|
||||
// document editor from the real doc metadata carried on the
|
||||
// tool result so "create a document" never leaves only a chat
|
||||
// link behind if the later doc_update event is missed.
|
||||
if (
|
||||
documentModule
|
||||
&& json.doc_id
|
||||
&& ['create_document', 'update_document', 'edit_document'].includes(json.tool)
|
||||
) {
|
||||
documentModule.handleDocUpdate({
|
||||
type: 'doc_update',
|
||||
doc_id: json.doc_id,
|
||||
title: json.document_title || '',
|
||||
language: json.document_language || '',
|
||||
version: json.document_version || 1,
|
||||
content: json.document_content || '',
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule a thinking spinner between tool rounds (short delay so
|
||||
// agent_step in the same SSE chunk can cancel it before it shows)
|
||||
@@ -2824,7 +2986,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
}
|
||||
|
||||
// Finalize the last round's bubble — flatten stream-content wrapper for clean DOM
|
||||
const finalDisplay = stripToolBlocks(_stripDocumentFenceForChat(roundText, { final: _docFenceOpened }));
|
||||
const finalDisplay = _streamDisplayText(roundText, { final: _docFenceOpened });
|
||||
if (finalDisplay.trim()) {
|
||||
var _body4 = roundHolder.querySelector('.body');
|
||||
// Preserve sources expanded state before final render
|
||||
@@ -2890,11 +3052,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
_body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml;
|
||||
} else if (roundHolder !== holder) {
|
||||
// Check if there's thinking content worth showing
|
||||
const _thinkingOnly = markdownModule.extractThinkingBlocks(roundText);
|
||||
const _thinkingOnly = markdownModule.extractThinkingBlocks(_streamDisplayText(roundText));
|
||||
if (_thinkingOnly.thinkingBlocks?.length && !_thinkingOnly.content) {
|
||||
// Show thinking in a collapsed section even if no visible reply text
|
||||
const _body4c = roundHolder.querySelector('.body');
|
||||
if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText);
|
||||
if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(_streamDisplayText(roundText));
|
||||
} else {
|
||||
roundHolder.style.display = 'none';
|
||||
// Thread above expected a bubble below — remove has-bottom since bubble is hidden
|
||||
@@ -3109,6 +3271,21 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortReason === 'stale-local') {
|
||||
const staleMsg = 'Stream connection ended. Composer unlocked; send again if needed.';
|
||||
if (holder && !accumulated) {
|
||||
holder.querySelector('.body').innerHTML =
|
||||
`<div style="opacity:0.7;font-style:italic;padding:4px 0;">[${staleMsg}]</div>`;
|
||||
} else if (holder && accumulated) {
|
||||
const staleNote = document.createElement('div');
|
||||
staleNote.className = 'stopped-indicator';
|
||||
staleNote.innerHTML = `<span style="opacity:0.7;">[${staleMsg}]</span>`;
|
||||
holder.querySelector('.body').appendChild(staleNote);
|
||||
}
|
||||
currentAbort = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// User-initiated stop (or browser navigation abort).
|
||||
// Stopped before any text arrived — keep the bubble as a
|
||||
// "Cancelled by user" record (so it survives a refresh).
|
||||
@@ -3415,12 +3592,51 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
box.appendChild(bar);
|
||||
if (uiModule.scrollHistory) uiModule.scrollHistory();
|
||||
}
|
||||
async function _probeStaleLocalStream() {
|
||||
if (!isStreaming || _staleStreamProbeInFlight) return;
|
||||
if (Date.now() - _lastReaderActivity < STALE_LOCAL_STREAM_MS) return;
|
||||
const sid = _streamSessionId || (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId());
|
||||
if (!sid) return;
|
||||
if (_backgroundStreams.has(sid) || (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() !== sid)) return;
|
||||
_staleStreamProbeInFlight = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/chat/stream_status/${encodeURIComponent(sid)}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!isStreaming || _backgroundStreams.has(sid)) return;
|
||||
if (res.status !== 404) return;
|
||||
|
||||
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
|
||||
if (currentAbort && !currentAbort.signal.aborted) {
|
||||
currentAbort._reason = 'stale-local';
|
||||
currentAbort.abort();
|
||||
}
|
||||
isStreaming = false;
|
||||
_setForegroundChatBusy(false);
|
||||
_sendInFlight = false;
|
||||
if (_webLockRelease) {
|
||||
_webLockRelease();
|
||||
_webLockRelease = null;
|
||||
}
|
||||
const submitBtn = document.querySelector('.send-btn');
|
||||
if (submitBtn) updateSubmitButton('idle', submitBtn);
|
||||
const messageInput = uiModule.el('message');
|
||||
if (messageInput) messageInput.disabled = false;
|
||||
_drainQueuedAgentRequests();
|
||||
} catch (err) {
|
||||
console.warn('[stream-watchdog] Stream status probe failed:', err);
|
||||
} finally {
|
||||
_staleStreamProbeInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _startStallWatchdog() {
|
||||
// Disabled: the server-side stall detector / auto-continue (agent
|
||||
// loop-breaker) handles quiet/stalled streams now, so the manual
|
||||
// "Quiet for Nm — still working?" banner is redundant (and annoying).
|
||||
// Keep the old noisy stall banner disabled. This watchdog only unlocks
|
||||
// a dead local stream after the backend confirms no active stream exists.
|
||||
if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; }
|
||||
_removeStallBanner();
|
||||
_stallWatchdog = setInterval(_probeStaleLocalStream, 5000);
|
||||
}
|
||||
function _stopStallWatchdog() {
|
||||
if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; }
|
||||
@@ -3582,7 +3798,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
};
|
||||
|
||||
const renderDelta = () => {
|
||||
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText, { final: docFenceOpened })));
|
||||
const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText, { final: docFenceOpened }));
|
||||
if (docFenceOpened && !dt.trim()) {
|
||||
_showDocumentWritingStatus(contentDiv);
|
||||
} else {
|
||||
|
||||
+73
-19
@@ -474,6 +474,10 @@ const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
|
||||
// (e.g. mid-stream before the closing tag arrives).
|
||||
const DSML_TOOL_RE = /<\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>|$)/gi;
|
||||
const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi;
|
||||
const DSML_INVOKE_RE = /<\s*[||]+\s*DSML\s*[||]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*invoke\s*>|$)/gi;
|
||||
const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi;
|
||||
const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
|
||||
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
|
||||
// Self-narration about tool results (model echoing stdout/exit_code)
|
||||
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
|
||||
|
||||
@@ -911,9 +915,13 @@ export function stripToolBlocks(text) {
|
||||
let cleaned = text.replace(TOOL_CALL_RE, '');
|
||||
if (EXEC_FENCE_RE) cleaned = cleaned.replace(EXEC_FENCE_RE, stripExecutedFence);
|
||||
cleaned = cleaned.replace(DSML_TOOL_RE, '');
|
||||
cleaned = cleaned.replace(DSML_INVOKE_RE, '');
|
||||
cleaned = cleaned.replace(DSML_STRAY_RE, '');
|
||||
cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
|
||||
cleaned = cleaned.replace(XML_INVOKE_RE, '');
|
||||
cleaned = cleaned.replace(RAW_OPENAI_TOOL_JSON_RE, '');
|
||||
cleaned = cleaned.replace(QWEN_ROLE_MARKER_RE, '');
|
||||
cleaned = cleaned.replace(QWEN_BARE_MARKER_RE, ' ');
|
||||
cleaned = cleaned.replace(TOOL_NARRATION_RE, '');
|
||||
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
||||
return cleaned.trim();
|
||||
@@ -1152,17 +1160,41 @@ document.addEventListener('click', function(e) {
|
||||
while (_t && _t.nodeType === Node.TEXT_NODE) _t = _t.parentElement;
|
||||
const a = _t && _t.closest && _t.closest('a[href]');
|
||||
if (!a) return;
|
||||
const href = a.getAttribute('href') || '';
|
||||
const rawHref = a.getAttribute('href') || '';
|
||||
let href = rawHref;
|
||||
try {
|
||||
const parsed = new URL(rawHref, window.location.origin);
|
||||
if (parsed.origin === window.location.origin && parsed.pathname === window.location.pathname) {
|
||||
href = parsed.hash || rawHref;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!href.startsWith('#')) return;
|
||||
const m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/);
|
||||
let m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/);
|
||||
if (!m) {
|
||||
const noteOpen = href.match(/^#open=notes¬e=([^&]+)/);
|
||||
if (noteOpen) m = ['note', 'note', decodeURIComponent(noteOpen[1])];
|
||||
}
|
||||
if (!m) {
|
||||
const bareSession = href.match(/^#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
|
||||
if (bareSession) m = ['session', 'session', bareSession[1]];
|
||||
}
|
||||
if (!m) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const [, kind, id] = m;
|
||||
if (kind === 'session') {
|
||||
try {
|
||||
a.classList.add('is-loading');
|
||||
a.setAttribute('aria-busy', 'true');
|
||||
} catch {}
|
||||
import('./sessions.js').then(mod => {
|
||||
const fn = mod.selectSession || (mod.default && mod.default.selectSession);
|
||||
if (fn) fn(id);
|
||||
if (fn) return fn(id, { showLoading: true, immediateLoading: true });
|
||||
}).finally(() => {
|
||||
try {
|
||||
a.classList.remove('is-loading');
|
||||
a.removeAttribute('aria-busy');
|
||||
} catch {}
|
||||
});
|
||||
} else if (kind === 'document') {
|
||||
import('./document.js').then(mod => {
|
||||
@@ -1175,6 +1207,11 @@ document.addEventListener('click', function(e) {
|
||||
import('./notes.js').then(mod => {
|
||||
const open = mod.openNote || (mod.default && mod.default.openNote);
|
||||
if (open) open(id);
|
||||
try {
|
||||
if (/^#(?:note-|open=notes¬e=)/.test(window.location.hash || '')) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
} catch (_) {}
|
||||
}).catch(() => {});
|
||||
} else if (kind === 'image') {
|
||||
import('./gallery.js').then(mod => {
|
||||
@@ -1208,7 +1245,7 @@ document.addEventListener('click', function(e) {
|
||||
if (open) open(id);
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
|
||||
/**
|
||||
* Build a generated-image bubble element.
|
||||
@@ -1326,6 +1363,25 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
|
||||
});
|
||||
actions.appendChild(editBtn);
|
||||
|
||||
if (imageId) {
|
||||
const galleryBtn = document.createElement('button');
|
||||
galleryBtn.className = 'footer-copy-btn footer-open-gallery-btn';
|
||||
galleryBtn.type = 'button';
|
||||
galleryBtn.title = 'Open in gallery';
|
||||
galleryBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg><span>Open in gallery</span>';
|
||||
galleryBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const mod = await import('./gallery.js');
|
||||
const open = mod.openGalleryImage || (mod.default && mod.default.openGalleryImage);
|
||||
if (open) open(imageId);
|
||||
} catch (err) {
|
||||
console.error('[chat] open in gallery failed', err);
|
||||
}
|
||||
});
|
||||
actions.appendChild(galleryBtn);
|
||||
}
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'footer-copy-btn footer-delete-btn';
|
||||
delBtn.type = 'button';
|
||||
@@ -1789,19 +1845,16 @@ export function displayMetrics(messageElement, metrics) {
|
||||
}
|
||||
}
|
||||
|
||||
// Default: show tok/s if available, else fall back to other stats
|
||||
// Keep token counts in the Message Stats popup; the footer should stay slim.
|
||||
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
|
||||
const metricsLabel = tps != null && tps !== 'undefined'
|
||||
const hasTps = tps != null && tps !== 'undefined';
|
||||
const metricsLabel = hasTps
|
||||
? `${tps} tok/s`
|
||||
: costStr0
|
||||
? `${outputTokens} tok · ${costStr0}`
|
||||
: outputTokens
|
||||
? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}`
|
||||
: inputTokens
|
||||
? `${inputTokens} in${responseTime != null ? ' · ' + responseTime + 's' : ''}`
|
||||
: responseTime != null
|
||||
? `${responseTime}s`
|
||||
: '';
|
||||
? costStr0
|
||||
: responseTime != null
|
||||
? `${responseTime}s`
|
||||
: '';
|
||||
if (!metricsLabel) return;
|
||||
metricsContainer.textContent = metricsLabel;
|
||||
metricsContainer.style.cursor = 'pointer';
|
||||
@@ -2442,8 +2495,8 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
wrap.dataset.raw = text;
|
||||
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
|
||||
wrap.dataset.raw = text;
|
||||
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
|
||||
// Prepend sources box if saved in metadata
|
||||
var sourcesPrefix = '';
|
||||
var findingsSuffix = '';
|
||||
@@ -2466,9 +2519,10 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
'<think' + (thinkTime ? ` time="${thinkTime}"` : '') + '>' + metadata.thinking + '</think>\n\n' + text
|
||||
);
|
||||
b.innerHTML = sourcesPrefix + thinkHtml + findingsSuffix;
|
||||
} else {
|
||||
b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix;
|
||||
}
|
||||
} else {
|
||||
b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix;
|
||||
}
|
||||
b.dataset.raw = text;
|
||||
|
||||
// The vision/OCR caption is stripped from the displayed text above (so the
|
||||
// bubble doesn't show the raw model output) but no longer rendered as an
|
||||
|
||||
+10
-2
@@ -185,9 +185,17 @@ export function handleUIControl(uiData) {
|
||||
|
||||
} else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') {
|
||||
try {
|
||||
var existingDocId = documentModule && documentModule.findEmailDocId
|
||||
? documentModule.findEmailDocId(uiData.uid, uiData.folder || 'INBOX')
|
||||
var activeCtx = documentModule && documentModule.getActiveEmailComposerContext
|
||||
? documentModule.getActiveEmailComposerContext()
|
||||
: null;
|
||||
var sameActiveDraft = activeCtx
|
||||
&& String(activeCtx.sourceUid || '') === String(uiData.uid || '')
|
||||
&& String(activeCtx.sourceFolder || 'INBOX') === String(uiData.folder || 'INBOX');
|
||||
var existingDocId = sameActiveDraft && activeCtx.docId
|
||||
? activeCtx.docId
|
||||
: (documentModule && documentModule.findEmailDocId
|
||||
? documentModule.findEmailDocId(uiData.uid, uiData.folder || 'INBOX')
|
||||
: null);
|
||||
if (existingDocId && documentModule.replaceEmailReplyBody) {
|
||||
if (documentModule.loadDocument) documentModule.loadDocument(existingDocId);
|
||||
documentModule.replaceEmailReplyBody(existingDocId, uiData.body || '', { force: true });
|
||||
|
||||
@@ -49,6 +49,16 @@ const _RECIPES = [
|
||||
},
|
||||
},
|
||||
|
||||
// ── MLX ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
backend: 'mlx_lm',
|
||||
label: 'Any MLX model',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['uv pip install -U mlx-lm'] },
|
||||
},
|
||||
},
|
||||
|
||||
// ── llama.cpp ─────────────────────────────────────────────────────────
|
||||
{
|
||||
backend: 'llama_cpp',
|
||||
@@ -75,7 +85,7 @@ export function recipeCommands(recipe, variant) {
|
||||
// Backends we surface a recipe panel for. Other rows in the Dependencies
|
||||
// list keep the existing flat Install/Reinstall button without an expand
|
||||
// affordance.
|
||||
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'llama_cpp']);
|
||||
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'mlx_lm', 'llama_cpp']);
|
||||
|
||||
// All recipe entries for a given backend, in catalog order. The first one
|
||||
// is the model-specific match (when present); the last is always the
|
||||
|
||||
+229
-10
@@ -149,6 +149,118 @@ function _openCpuServeEdit(panel) {
|
||||
});
|
||||
}
|
||||
|
||||
function _taskForDiagnosisPanel(panel) {
|
||||
const taskEl = panel?.closest?.('.cookbook-task');
|
||||
const taskId = taskEl?.dataset?.taskId || '';
|
||||
if (!taskId) return null;
|
||||
return (_loadTasks() || []).find(t => t.sessionId === taskId) || null;
|
||||
}
|
||||
|
||||
function _pythonFromServeCmd(cmd) {
|
||||
const s = String(cmd || '');
|
||||
const abs = s.match(/(?:^|\s)(\/[^\s]+\/bin\/python3?)(?=\s+-m\s+(?:sglang\.launch_server|mlx_lm\.server))/);
|
||||
if (abs) return abs[1];
|
||||
const rel = s.match(/(?:^|\s)(python3?)(?=\s+-m\s+(?:sglang\.launch_server|mlx_lm\.server))/);
|
||||
return rel ? rel[1] : '';
|
||||
}
|
||||
|
||||
function _pythonForDiagnosisPanel(panel) {
|
||||
const task = _taskForDiagnosisPanel(panel);
|
||||
const fromCmd = _pythonFromServeCmd(task?.payload?._cmd || '');
|
||||
if (fromCmd) return fromCmd;
|
||||
return (_envState.env === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`
|
||||
: 'python3';
|
||||
}
|
||||
|
||||
function _sglangKernelRepairCommand(panel) {
|
||||
return `${_pythonForDiagnosisPanel(panel)} -m pip install -U --force-reinstall --no-cache-dir sglang-kernel`;
|
||||
}
|
||||
|
||||
function _mlxLmInstallCommand(panel) {
|
||||
return `${_pythonForDiagnosisPanel(panel)} -m pip install -U mlx-lm`;
|
||||
}
|
||||
|
||||
async function _repairSglangKernel(panel) {
|
||||
const task = _taskForDiagnosisPanel(panel);
|
||||
uiModule.showToast('Repairing sglang-kernel on the selected server...');
|
||||
await _launchServeTask(
|
||||
'repair-sglang-kernel',
|
||||
'pip-update',
|
||||
_sglangKernelRepairCommand(panel),
|
||||
null,
|
||||
task?.remoteHost || undefined,
|
||||
task ? {
|
||||
serverKey: task.remoteServerKey || task.remoteHost || '',
|
||||
serverName: task.remoteServerName || task.remoteHost || '',
|
||||
} : null,
|
||||
);
|
||||
}
|
||||
|
||||
async function _installMlxLm(panel) {
|
||||
const task = _taskForDiagnosisPanel(panel);
|
||||
uiModule.showToast('Installing MLX LM on the selected server...');
|
||||
await _launchServeTask(
|
||||
'install-mlx-lm',
|
||||
'pip-update',
|
||||
_mlxLmInstallCommand(panel),
|
||||
null,
|
||||
task?.remoteHost || undefined,
|
||||
_diagnosisTargetMeta(task),
|
||||
);
|
||||
}
|
||||
|
||||
function _diagnosisTargetMeta(task) {
|
||||
return task ? {
|
||||
serverKey: task.remoteServerKey || task.remoteHost || '',
|
||||
serverName: task.remoteServerName || task.remoteHost || '',
|
||||
} : null;
|
||||
}
|
||||
|
||||
function _gpuCleanupCommand() {
|
||||
return `set -u
|
||||
echo "[odysseus] Clearing GPU compute processes..."
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
pids="$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null | tr -d " " | grep -E "^[0-9]+$" | sort -u)"
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[odysseus] No NVIDIA compute processes found."
|
||||
exit 0
|
||||
fi
|
||||
echo "[odysseus] GPU PIDs: $pids"
|
||||
ps -fp $pids 2>/dev/null || true
|
||||
echo "[odysseus] Sending TERM..."
|
||||
kill -TERM $pids || true
|
||||
sleep 3
|
||||
alive=""
|
||||
for pid in $pids; do
|
||||
if kill -0 "$pid" 2>/dev/null; then alive="$alive $pid"; fi
|
||||
done
|
||||
if [ -n "$alive" ]; then
|
||||
echo "[odysseus] Force killing remaining GPU PIDs:$alive"
|
||||
kill -KILL $alive || true
|
||||
fi
|
||||
sleep 1
|
||||
remaining="$(nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv,noheader,nounits 2>/dev/null | sed "/^$/d" || true)"
|
||||
if [ -n "$remaining" ]; then
|
||||
echo "[odysseus] GPU processes still remain:"
|
||||
echo "$remaining"
|
||||
exit 2
|
||||
fi
|
||||
echo "[odysseus] GPU cleanup complete. No NVIDIA compute processes remain."
|
||||
else
|
||||
echo "[odysseus] nvidia-smi not found; falling back to common model-server process cleanup."
|
||||
pkill -TERM -f "sglang.launch_server|vllm|llama-server|text-generation-launcher|aphrodite" || true
|
||||
sleep 3
|
||||
pkill -KILL -f "sglang.launch_server|vllm|llama-server|text-generation-launcher|aphrodite" || true
|
||||
echo "[odysseus] Fallback cleanup complete."
|
||||
fi`;
|
||||
}
|
||||
|
||||
async function _clearGpuProcesses(panel) {
|
||||
uiModule.showToast('Clearing GPU compute processes on the selected server...');
|
||||
await _runQuickCmd(panel, _gpuCleanupCommand());
|
||||
}
|
||||
|
||||
// Infer the gated base repo that single-file checkpoints need configs from
|
||||
function _inferBaseRepo(text) {
|
||||
if (!text) return null;
|
||||
@@ -161,6 +273,25 @@ function _inferBaseRepo(text) {
|
||||
}
|
||||
|
||||
export const ERROR_PATTERNS = [
|
||||
{
|
||||
pattern: /tmux is required|tmux.*not found|tmux:\s*command not found|command not found:\s*tmux|No such file or directory:\s*['"]?tmux/i,
|
||||
message: 'tmux is missing on this server.',
|
||||
suggestion: 'Suggested action: open Dependencies and install tmux on the selected server.',
|
||||
fixes: [
|
||||
{ label: 'Open tmux dependency', action: () => _openCookbookDependencies('tmux') },
|
||||
{ label: 'Copy apt install', action: () => _copyText('sudo apt install -y tmux') },
|
||||
{ label: 'Copy pacman install', action: () => _copyText('sudo pacman -S --needed tmux') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /Port \d+ is already serving|port is occupied by a different model|choose another port before launching/i,
|
||||
message: 'Serve port is already occupied by another model.',
|
||||
suggestion: 'Suggested action: stop the old server or choose a different port before relaunching.',
|
||||
fixes: [
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
{ label: 'Copy check command', action: () => _copyText('curl http://127.0.0.1:PORT/v1/models') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /No available memory for the cache blocks|Available KV cache memory:.*-/i,
|
||||
message: 'No GPU memory left for KV cache after loading model.',
|
||||
@@ -179,6 +310,39 @@ export const ERROR_PATTERNS = [
|
||||
{ label: 'Retry with --max-num-seqs 32', action: (panel) => _serveAutoRetry(panel, '--max-num-seqs 32') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /Loaded weights leave no GPU memory for the KV cache under --mem-fraction-static|Raise --mem-fraction-static above/i,
|
||||
message: 'SGLang static memory fraction is too low for the loaded weights.',
|
||||
suggestion: 'Suggested action: retry with --mem-fraction-static 0.80 so weights fit and KV cache can still allocate.',
|
||||
fixes: [
|
||||
{ label: 'Retry mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.80') },
|
||||
{ label: 'Retry mem 0.82', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.82') },
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /get_paged_mqa_logits_metadata|deepseek_v4_backend\.py|paged_mqa_metadata\.cuh:113.*CUDA error:\s*invalid argument/i,
|
||||
message: 'SGLang DeepSeek-V4 attention metadata kernel failed on this GPU/runtime.',
|
||||
suggestion: 'Suggested action: stop retrying graph/memory tweaks for this exact FP8 command. SGLang’s RTX PRO 6000 recipe uses the original deepseek-ai/DeepSeek-V4-Flash checkpoint with --moe-runner-backend marlin, not the converted sgl-project FP8 checkpoint. Try that recipe/checkpoint, official SGLang container/nightly, or supported Hopper/Blackwell hardware.',
|
||||
fixes: [
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
{ label: 'Copy error', action: (panel) => {
|
||||
const task = panel.closest('.cookbook-task');
|
||||
const text = task?.querySelector('.cookbook-task-output')?.textContent || task?.textContent || '';
|
||||
_copyText(text.trim());
|
||||
} },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /Capture cuda graph failed|cuda graph failed|paged_mqa_metadata|cuda-graph-backend-decode|cuda-graph-max-bs-decode|CUDA error:\s*invalid argument/i,
|
||||
message: 'SGLang failed while capturing decode CUDA graphs.',
|
||||
suggestion: 'Suggested action: disable SGLang decode CUDA graph for this launch. DeepSeek-V4 is reaching graph capture, but this kernel is failing on the target hardware.',
|
||||
fixes: [
|
||||
{ label: 'Disable decode graph', action: (panel) => _serveAutoRetryReplace(panel, '--cuda-graph-backend-decode', 'disabled') },
|
||||
{ label: 'Retry mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.80') },
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory/i,
|
||||
message: 'GPU ran out of memory. Try more GPUs (higher TP) or lower context.',
|
||||
@@ -334,6 +498,18 @@ export const ERROR_PATTERNS = [
|
||||
{ label: 'Enable enforce eager', action: (panel) => _setPanelCheckbox(panel, 'enforce_eager', true) },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /memory capacity is unbalanced|Some GPUs may be occupied by other processes|pre_model_load_memory=.*local_gpu_memory/i,
|
||||
message: 'SGLang refused to start because free GPU memory is uneven across the selected tensor-parallel GPUs.',
|
||||
suggestion: 'Suggested action: run Clear GPUs, then relaunch. If it still fails, choose only equally free GPUs or lower TP/context.',
|
||||
fixes: [
|
||||
{ label: 'Clear GPUs', action: (panel) => _clearGpuProcesses(panel) },
|
||||
{ label: 'Copy clear command', action: () => _copyText(_gpuCleanupCommand()) },
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
{ label: 'Set TP to 1', action: (panel) => _setPanelField(panel, 'tp', '1') },
|
||||
{ label: 'Lower context', action: (panel) => _setPanelField(panel, 'ctx', '32768') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context/i,
|
||||
message: 'Context length too large for available GPU memory.',
|
||||
@@ -355,11 +531,14 @@ export const ERROR_PATTERNS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|Please ensure sgl_kernel is properly installed/i,
|
||||
message: 'SGLang native dependencies are missing on this server.',
|
||||
pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|(?:Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|Could not load any common_ops library|Please ensure sgl_kernel is properly installed/i,
|
||||
message: 'SGLang native kernel/runtime is missing or mismatched on this server.',
|
||||
suggestion: 'Suggested action: relaunch with Odysseus’ venv CUDA library path fix. If the venv does not contain the matching NVIDIA runtime libs, run Repair sglang-kernel.',
|
||||
fixes: [
|
||||
{ label: 'Edit / relaunch serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
{ label: 'Repair sglang-kernel', action: (panel) => _repairSglangKernel(panel) },
|
||||
{ label: 'Copy repair command', action: (panel) => _copyText(_sglangKernelRepairCommand(panel)) },
|
||||
{ label: 'Copy OS package command', action: () => _copyText('sudo apt-get install -y libnuma-dev python3.12-dev build-essential') },
|
||||
{ label: 'Copy kernel upgrade', action: () => _copyText('python3 -m pip install --upgrade sglang-kernel') },
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
|
||||
],
|
||||
},
|
||||
@@ -371,6 +550,30 @@ export const ERROR_PATTERNS = [
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "sglang[all]"') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /No module named ['"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed/i,
|
||||
message: 'MLX LM is not installed on this server.',
|
||||
suggestion: 'Suggested action: install mlx-lm in the selected Python environment. MLX serving is intended for Apple Silicon Macs.',
|
||||
fixes: [
|
||||
{ label: 'Install MLX LM', action: (panel) => _installMlxLm(panel) },
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mlx_lm') },
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mlx-lm') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /Unable to quantize model of type <class ['"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['"]>|QuantizedSwitchLinear/i,
|
||||
message: 'MLX-LM tried to quantize an already-quantized DeepSeek switch layer.',
|
||||
suggestion: 'Suggested action: relaunch from the cached local snapshot path. Odysseus now rewrites MLX repo-id launches to the newest local Hugging Face snapshot when it exists on the selected Mac.',
|
||||
fixes: [
|
||||
{ label: 'Edit / relaunch serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mlx_lm') },
|
||||
{ label: 'Copy error', action: (panel) => {
|
||||
const task = panel.closest('.cookbook-task');
|
||||
const text = task?.querySelector('.cookbook-task-output')?.textContent || task?.textContent || '';
|
||||
_copyText(text.trim());
|
||||
} },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /No accelerator \(CUDA, XPU, HPU, NPU, MUSA, MPS\) is available|Triton is not supported on current platform/i,
|
||||
message: 'SGLang needs a visible GPU/accelerator on this server.',
|
||||
@@ -834,22 +1037,38 @@ export function _clearDiagnosis(panel) {
|
||||
// ── Quick command ──
|
||||
|
||||
export async function _runQuickCmd(panel, cmd) {
|
||||
const task = _taskForDiagnosisPanel(panel);
|
||||
let fullCmd = cmd;
|
||||
if (_envState.remoteHost) {
|
||||
fullCmd = _sshCmd(_envState.remoteHost, cmd);
|
||||
const host = task?.remoteHost || _envState.remoteHost || '';
|
||||
const port = task?.sshPort || task?.payload?.ssh_port || _envState.sshPort || '';
|
||||
if (host) {
|
||||
fullCmd = _sshCmd(host, cmd, port);
|
||||
}
|
||||
const diag = panel.querySelector('.cookbook-diagnosis');
|
||||
if (diag) { diag.classList.remove('hidden'); diag.textContent = `Running: ${fullCmd}...`; }
|
||||
if (diag) {
|
||||
diag.classList.remove('hidden');
|
||||
diag.innerHTML = '<div class="cookbook-diag-message">Running command...</div>';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/shell/stream', {
|
||||
const res = await fetch('/api/shell/exec', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: fullCmd }),
|
||||
body: JSON.stringify({ command: fullCmd, timeout: 60 }),
|
||||
});
|
||||
if (diag) diag.textContent = res.ok ? `Done: ${cmd}` : `Failed (HTTP ${res.status})`;
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const out = [data.stdout, data.stderr].filter(Boolean).join('\n').trim();
|
||||
const ok = res.ok && Number(data.exit_code ?? 1) === 0;
|
||||
if (diag) {
|
||||
diag.innerHTML = ''
|
||||
+ `<div class="cookbook-diag-message">${ok ? 'Command completed.' : 'Command failed.'}</div>`
|
||||
+ `<div class="cookbook-diag-suggestion" style="opacity:0.75;margin-top:1px;">Exit code: ${_diagEsc(data.exit_code ?? 'unknown')}</div>`
|
||||
+ (out ? `<pre class="cookbook-diag-output" style="margin:6px 0 0;white-space:pre-wrap;max-height:180px;overflow:auto;font-size:10px;line-height:1.35;">${_diagEsc(out)}</pre>` : '');
|
||||
}
|
||||
} catch (e) {
|
||||
if (diag) diag.textContent = `Error: ${e.message}`;
|
||||
if (diag) {
|
||||
diag.innerHTML = `<div class="cookbook-diag-message">Command error.</div><div class="cookbook-diag-suggestion">${_diagEsc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+140
-44
@@ -24,6 +24,9 @@ import {
|
||||
_MODELDIR_CHECK_ON,
|
||||
_MODELDIR_CHECK_OFF,
|
||||
_serverEntryHtml,
|
||||
_serverDefaultHtml,
|
||||
_applyServerSelectColor,
|
||||
_syncServerSelectColors,
|
||||
_copyText,
|
||||
// Import cookbook.js WITHOUT a ?v= query — the same plain specifier every other
|
||||
// importer uses. A query mismatch loads cookbook.js twice as two separate modules
|
||||
@@ -34,10 +37,59 @@ import spinnerModule from './spinner.js';
|
||||
import { _loadTasks, _tmuxGracefulKill, _nextAvailablePort, _taskPort } from './cookbookRunning.js';
|
||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
|
||||
// Map a serve-backend code (vllm / sglang / llamacpp) → the package name
|
||||
// Map a serve-backend code (vllm / sglang / llamacpp / mlx) → the package name
|
||||
// the Dependencies API reports. Used to look up "is this backend installed
|
||||
// on the target server" before firing a launch.
|
||||
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp' };
|
||||
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm' };
|
||||
|
||||
function _normalizeCookbookModelDir(dir) {
|
||||
const d = String(dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim();
|
||||
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
|
||||
}
|
||||
|
||||
function _wireServerColorPicker(entry) {
|
||||
const wrap = entry.querySelector('.cookbook-srv-color-wrap');
|
||||
const select = entry.querySelector('.cookbook-srv-color');
|
||||
const btn = entry.querySelector('.cookbook-srv-color-btn');
|
||||
const menu = entry.querySelector('.cookbook-srv-color-menu');
|
||||
if (!wrap || !select || !btn || !menu || btn.dataset.bound) return;
|
||||
btn.dataset.bound = '1';
|
||||
const close = () => {
|
||||
menu.classList.add('hidden');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
};
|
||||
const open = () => {
|
||||
document.querySelectorAll('.cookbook-srv-color-menu').forEach(m => {
|
||||
if (m !== menu) m.classList.add('hidden');
|
||||
});
|
||||
menu.classList.remove('hidden');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
};
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (menu.classList.contains('hidden')) open();
|
||||
else close();
|
||||
});
|
||||
menu.querySelectorAll('.cookbook-srv-color-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const color = item.dataset.color || '';
|
||||
select.value = color;
|
||||
const label = item.querySelector('span:last-child')?.textContent || 'Auto';
|
||||
const labelEl = btn.querySelector('.cookbook-srv-color-label');
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
const swatch = item.style.getPropertyValue('--swatch-color') || color;
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(swatch.trim())) {
|
||||
entry.style.setProperty('--cookbook-server-color', swatch.trim());
|
||||
wrap.style.setProperty('--cookbook-server-color', swatch.trim());
|
||||
}
|
||||
menu.querySelectorAll('.cookbook-srv-color-item').forEach(b => b.classList.toggle('active', b === item));
|
||||
close();
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', close);
|
||||
}
|
||||
|
||||
// Pre-launch: ask the deps API whether the chosen backend is present on
|
||||
// the target server. Returns true if it's good to go, false if we should
|
||||
@@ -80,7 +132,6 @@ export let _cachedModelIds = null; // repo IDs already downloaded
|
||||
// after the user has switched servers.
|
||||
let _hwfitFetchToken = 0;
|
||||
let _dismissedHwChips = new Set();
|
||||
let _hwfitAutoScanStarted = new Set();
|
||||
// Permanently removed (X-clicked) chips. Separate from _dismissedHwChips
|
||||
// so the ranker treats "off" and "removed" the same (both ignore the
|
||||
// hardware) but the UI keeps "off" chips visible to toggle back on,
|
||||
@@ -407,15 +458,12 @@ function _manualDisplaySystem(sys, manual) {
|
||||
// Signature of everything that affects the result list, so we never paint a
|
||||
// cached list under mismatched filters.
|
||||
function _scanSig() {
|
||||
const sortEl = document.getElementById('hwfit-sort');
|
||||
const tc = document.getElementById('hwfit-gpu-toggles');
|
||||
return JSON.stringify({
|
||||
h: _envState.remoteHost || '',
|
||||
hk: _currentServerValue(),
|
||||
u: document.getElementById('hwfit-usecase')?.value || '',
|
||||
s: document.getElementById('hwfit-search')?.value?.trim() || '',
|
||||
o: sortEl?.value || 'newest',
|
||||
r: sortEl?.dataset.reverse === '1' ? 1 : 0,
|
||||
q: document.getElementById('hwfit-quant')?.value || '',
|
||||
c: _ctxValue(),
|
||||
g: (tc && typeof tc._activeCount === 'number') ? String(tc._activeCount) : '',
|
||||
@@ -534,6 +582,13 @@ function _olParseSize(s) {
|
||||
function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) {
|
||||
const out = [];
|
||||
if (!Array.isArray(libModels)) return out;
|
||||
const _ramFitLevel = (need, budget) => {
|
||||
if (!need || !budget || need > budget) return 'too_tight';
|
||||
const ratio = need / budget;
|
||||
if (ratio <= 0.50) return 'perfect';
|
||||
if (ratio <= 0.78) return 'good';
|
||||
return 'marginal';
|
||||
};
|
||||
for (const m of libModels) {
|
||||
const sizes = (Array.isArray(m.sizes) && m.sizes.length) ? m.sizes : ['latest'];
|
||||
for (const sz of sizes) {
|
||||
@@ -544,10 +599,10 @@ function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) {
|
||||
if (vramGb && vramAvail) {
|
||||
if (vramGb <= vramAvail * 0.6) fitLevel = 'perfect';
|
||||
else if (vramGb <= vramAvail) fitLevel = 'good';
|
||||
else if (ramAvail && vramGb <= ramAvail) fitLevel = 'marginal';
|
||||
else if (ramAvail && vramGb <= ramAvail) fitLevel = _ramFitLevel(vramGb, ramAvail);
|
||||
else fitLevel = 'too_tight';
|
||||
} else if (vramGb && ramAvail && vramGb <= ramAvail) {
|
||||
fitLevel = 'marginal';
|
||||
fitLevel = _ramFitLevel(vramGb, ramAvail);
|
||||
}
|
||||
const tag = `${m.name}:${sz}`;
|
||||
const paramsLabel = params
|
||||
@@ -628,21 +683,18 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
loadingTitle.textContent = 'No cached scan yet';
|
||||
loadingTitle.style.cssText = 'font-size:12px;opacity:0.7;';
|
||||
const loadingLbl = document.createElement('div');
|
||||
loadingLbl.textContent = 'Scanning hardware…';
|
||||
loadingLbl.textContent = 'Loading model list…';
|
||||
loadingLbl.style.cssText = 'font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;';
|
||||
loadingDiv.appendChild(loadingTitle);
|
||||
loadingDiv.appendChild(loadingLbl);
|
||||
list.innerHTML = '';
|
||||
list.appendChild(loadingDiv);
|
||||
if (!_hwfitAutoScanStarted.has(_sig)) {
|
||||
_hwfitAutoScanStarted.add(_sig);
|
||||
setTimeout(() => {
|
||||
if (_tk === _hwfitFetchToken) {
|
||||
_resetGpuToggleState();
|
||||
_hwfitFetch(true, { autoFromEmpty: true });
|
||||
}
|
||||
}, 60);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (_tk === _hwfitFetchToken) {
|
||||
_resetGpuToggleState();
|
||||
_hwfitFetch(true, { autoFromEmpty: true });
|
||||
}
|
||||
}, 60);
|
||||
return;
|
||||
}
|
||||
if (!canKeepPrevious) {
|
||||
@@ -653,13 +705,15 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
loadingDiv.style.flexDirection = 'column';
|
||||
loadingDiv.style.gap = '6px';
|
||||
loadingDiv.appendChild(wp.element);
|
||||
// Text label like the other cookbook tabs: "Loading…", then if the scan runs
|
||||
// long (remote SSH hardware probe), switch to "Scanning hardware…".
|
||||
// Text label like the other cookbook tabs. Only fresh rescans are hardware
|
||||
// probes; normal refreshes are just model ranking/loading from cached hw.
|
||||
const loadingLbl = document.createElement('div');
|
||||
loadingLbl.textContent = 'Loading…';
|
||||
loadingLbl.textContent = fresh ? 'Scanning hardware…' : 'Loading models…';
|
||||
loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;';
|
||||
loadingDiv.appendChild(loadingLbl);
|
||||
setTimeout(() => { if (loadingLbl.isConnected) loadingLbl.textContent = 'Scanning hardware…'; }, 2000);
|
||||
setTimeout(() => {
|
||||
if (loadingLbl.isConnected) loadingLbl.textContent = fresh ? 'Scanning hardware…' : 'Loading model list…';
|
||||
}, 2000);
|
||||
list.innerHTML = '';
|
||||
list.appendChild(loadingDiv);
|
||||
_hwfitCache = null; // no instant paint — clear until the fetch returns
|
||||
@@ -703,10 +757,6 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
_setLastCacheHost('');
|
||||
});
|
||||
}
|
||||
if (_paintedFromCache && !forceRevalidate) {
|
||||
try { wp.destroy(); } catch {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sortBy = document.getElementById('hwfit-sort')?.value || 'newest';
|
||||
const quantPref = document.getElementById('hwfit-quant')?.value || '';
|
||||
@@ -722,7 +772,10 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
if (!hasManualOrDismissed && toggleContainer && toggleContainer._activeGroup) {
|
||||
gpuGroupOverride = String(toggleContainer._activeGroup);
|
||||
}
|
||||
const params = new URLSearchParams({ limit: '80', sort: sortBy });
|
||||
// Sorting is a table operation, not a different backend query. Fetch a
|
||||
// broad candidate set once, then sort it client-side so VRAM/Params/etc.
|
||||
// do not appear to "filter out" rows by returning a different top-80 slice.
|
||||
const params = new URLSearchParams({ limit: '2500', sort: 'score' });
|
||||
if (fresh) params.set('fresh', '1'); // bypass the hardware-scan cache
|
||||
if (search) params.set('search', search);
|
||||
if (remoteHost) {
|
||||
@@ -743,6 +796,9 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
if (hasManualOrDismissed) params.set('_hw_override_ts', String(Date.now()));
|
||||
// Image models use a separate registry/endpoint
|
||||
const isImageMode = useCase === 'image_gen';
|
||||
if ((fresh || (_paintedFromCache && !search)) && !isImageMode) {
|
||||
params.set('refresh_catalog', '1'); // update HF-backed dynamic catalogs in the background
|
||||
}
|
||||
if (!isImageMode) {
|
||||
if (useCase) params.set('use_case', useCase);
|
||||
if (quantPref) params.set('quant', quantPref);
|
||||
@@ -1198,9 +1254,9 @@ function _modeLabel(model) {
|
||||
export const _hwfitColumns = [
|
||||
{ key: 'fit', label: 'Fit', cls: 'hwfit-fit' },
|
||||
{ key: 'newest', label: 'Model (latest)', cls: 'hwfit-name' },
|
||||
{ key: 'vram', label: 'VRAM', cls: 'hwfit-c-vram' },
|
||||
{ key: 'params',label: 'Param', cls: 'hwfit-c-params' },
|
||||
{ key: null, label: 'Quant', cls: 'hwfit-c-quant' },
|
||||
{ key: 'vram', label: 'VRAM', cls: 'hwfit-c-vram' },
|
||||
{ key: 'context',label: 'Ctx', cls: 'hwfit-c-ctx' },
|
||||
{ key: 'speed', label: 'Speed', cls: 'hwfit-c-speed' },
|
||||
{ key: 'score', label: 'Score', cls: 'hwfit-c-score' },
|
||||
@@ -1301,13 +1357,13 @@ export function _hwfitRenderList(el, models) {
|
||||
}
|
||||
}
|
||||
html += `<span class="hwfit-col hwfit-name">${modelLogo(m.name)}${esc(_short)}${_quantSuffix}${moeBadge}${imgBadge}${dlDot}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-params">${esc(pcount)}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-vram" title="Estimated loaded footprint for this quant/backend/context, including model weights and KV/runtime allowance.">${vramLabel}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-params" title="Original total model parameters, not quantized storage size.">${esc(pcount)}</span>`;
|
||||
// Truncate the Quant cell to 9 chars + ellipsis so long tags like
|
||||
// "FP4-MoE-Mixed" don't push neighboring columns. Full tag stays in title.
|
||||
const _qRaw = m.quant || '?';
|
||||
const _qShort = _qRaw.length > 9 ? _qRaw.slice(0, 9) + '…' : _qRaw;
|
||||
html += `<span class="hwfit-col hwfit-c-quant" title="${esc(_qRaw)}">${esc(_qShort)}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-vram">${vramLabel}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-ctx">${m.is_image_gen ? '\u2014' : ctx}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-speed">${m.is_image_gen ? '\u2014' : tps + ' t/s'}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-score">${score}</span>`;
|
||||
@@ -1356,14 +1412,13 @@ export function _hwfitRenderList(el, models) {
|
||||
if (e.target.closest('[data-fit-dot]')) {
|
||||
const on = !e.target.classList.contains('active');
|
||||
try { localStorage.setItem('hwfit_fit_only_v1', on ? '1' : '0'); } catch {}
|
||||
// Un-toggling the fit filter (off → showing too-tight rows again) is
|
||||
// typically because the user wants to see the LARGE models they can't
|
||||
// run yet — re-sort by VRAM descending so the biggest surface first.
|
||||
// Un-toggling the fit filter should still keep the list usable: show
|
||||
// nearest/smallest VRAM first, not a wall of impossible 7000G rows.
|
||||
if (!on) {
|
||||
const sortSel = document.getElementById('hwfit-sort');
|
||||
if (sortSel) {
|
||||
sortSel.value = 'vram';
|
||||
sortSel.dataset.reverse = '0'; // descending (biggest first)
|
||||
sortSel.dataset.reverse = '1'; // ascending (smallest VRAM first)
|
||||
}
|
||||
}
|
||||
_hwfitCache = null;
|
||||
@@ -1379,7 +1434,9 @@ export function _hwfitRenderList(el, models) {
|
||||
sel.dataset.reverse = sel.dataset.reverse === '1' ? '0' : '1';
|
||||
} else {
|
||||
sel.value = sortKey;
|
||||
sel.dataset.reverse = '0';
|
||||
// VRAM is most useful as "what fits / closest fit first"; descending
|
||||
// buries qwen/gemma-sized rows below absurd impossible footprints.
|
||||
sel.dataset.reverse = sortKey === 'vram' ? '1' : '0';
|
||||
}
|
||||
_hwfitFetch();
|
||||
});
|
||||
@@ -1751,6 +1808,9 @@ export function _expandModelRow(row, modelData) {
|
||||
cmd += ` --context-length ${maxCtx}`;
|
||||
cmd += ` --mem-fraction-static ${gpuUtil}`;
|
||||
cmd += ' --trust-remote-code';
|
||||
} else if (runBackend === 'mlx') {
|
||||
const bindHost = host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd = `python3 -m mlx_lm.server --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port}`;
|
||||
} else if (runBackend === 'llamacpp') {
|
||||
const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`;
|
||||
const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
|
||||
@@ -1868,6 +1928,7 @@ const _HWFIT_ENGINE_GLYPHS = {
|
||||
'': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line><circle cx="8" cy="6" r="2" fill="currentColor" stroke="none"></circle><circle cx="16" cy="12" r="2" fill="currentColor" stroke="none"></circle><circle cx="10" cy="18" r="2" fill="currentColor" stroke="none"></circle></svg>',
|
||||
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"></path><path d="M14 4l4 9 3-9"></path></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"></path><path d="M16 6v12"></path><path d="M20 6v12"></path></svg>',
|
||||
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"></path><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"></path></svg>',
|
||||
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
|
||||
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"></path></svg>',
|
||||
@@ -2040,15 +2101,22 @@ export function _hwfitInit() {
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
if (!sel) continue;
|
||||
const currentVal = sel.value;
|
||||
let html = `<option value="local">Local</option>`;
|
||||
const currentVal = sel.value || _currentServerValue();
|
||||
const localSrv = _envState.servers.find(s => !s.host || String(s.host).toLowerCase() === 'local') || {};
|
||||
const localColor = /^#[0-9a-fA-F]{6}$/.test(String(localSrv.color || '').trim()) ? String(localSrv.color).trim() : '';
|
||||
const localLabel = localSrv.name || 'Local';
|
||||
let html = `<option value="local"${localColor ? ` style="color:${uiModule.esc(localColor)};"` : ''}>${uiModule.esc(localColor ? `● ${localLabel}` : localLabel)}</option>`;
|
||||
_envState.servers.forEach((s, i) => {
|
||||
if (!s.host) return;
|
||||
const label = s.name || s.host || `Server ${i + 1}`;
|
||||
html += `<option value="${i}">${uiModule.esc(label)}</option>`;
|
||||
const color = /^#[0-9a-fA-F]{6}$/.test(String(s.color || '').trim()) ? String(s.color).trim() : '';
|
||||
html += `<option value="${uiModule.esc(_serverKey(s))}"${color ? ` style="color:${uiModule.esc(color)};"` : ''}>${uiModule.esc(color ? `● ${label}` : label)}</option>`;
|
||||
});
|
||||
sel.innerHTML = html;
|
||||
sel.value = currentVal;
|
||||
if (sel.selectedIndex < 0) sel.value = _currentServerValue();
|
||||
if (sel.selectedIndex < 0) sel.value = 'local';
|
||||
_applyServerSelectColor(sel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2066,13 +2134,15 @@ export function _hwfitInit() {
|
||||
const port = row.querySelector('.cookbook-srv-port')?.value.trim() || '';
|
||||
const env = row.querySelector('.cookbook-srv-env')?.value || 'none';
|
||||
const envPath = row.querySelector('.cookbook-srv-path')?.value.trim() || '';
|
||||
const colorRaw = row.querySelector('.cookbook-srv-color')?.value?.trim() || '';
|
||||
const color = /^#[0-9a-fA-F]{6}$/.test(colorRaw) ? colorRaw : '';
|
||||
// Collect model directories from tags. Read the authoritative data-dir
|
||||
// attribute, not textContent \u2014 the tag now also holds a download-target
|
||||
// icon, and textContent would fold the icon/\u2716 glyph into the path.
|
||||
const dirTags = entry.querySelectorAll('.cookbook-modeldir-tag');
|
||||
const modelDirs = [];
|
||||
dirTags.forEach(tag => {
|
||||
const d = (tag.dataset.dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim();
|
||||
const d = _normalizeCookbookModelDir(tag.dataset.dir || '');
|
||||
if (d) modelDirs.push(d);
|
||||
});
|
||||
if (!modelDirs.length) modelDirs.push('~/.cache/huggingface/hub');
|
||||
@@ -2080,7 +2150,7 @@ export function _hwfitInit() {
|
||||
const dlEl = entry.querySelector('.cookbook-modeldir-dl.active');
|
||||
const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : '';
|
||||
const platform = entry.dataset.platform || '';
|
||||
_envState.servers.push({ name, host: host || '', port, env, envPath, modelDirs, modelDir: modelDirs.filter(d => d !== '~/.cache/huggingface/hub')[0] || modelDirs[0], downloadDir, platform });
|
||||
_envState.servers.push({ name, host: host || '', port, env, envPath, color, modelDirs, modelDir: modelDirs.filter(d => d !== '~/.cache/huggingface/hub')[0] || modelDirs[0], downloadDir, platform });
|
||||
});
|
||||
// Do NOT auto-change the selected host here. _syncServers can run while the
|
||||
// servers DOM is mid-render — host fields that are disabled/readonly read as
|
||||
@@ -2259,8 +2329,7 @@ export function _hwfitInit() {
|
||||
document.querySelectorAll('.cookbook-srv-default').forEach(b => {
|
||||
const on = !!_envState.defaultServer && b.dataset.srvKey === _envState.defaultServer;
|
||||
b.classList.toggle('active', on);
|
||||
// Keep the "default" label after the icon (don't overwrite it).
|
||||
b.innerHTML = (on ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF) + '<span class="cookbook-srv-default-label">default</span>';
|
||||
b.innerHTML = _serverDefaultHtml(on);
|
||||
b.title = on ? 'Default server — Cookbook opens here' : 'Make this the default server';
|
||||
});
|
||||
// Apply immediately so the dropdowns reflect it without reopening
|
||||
@@ -2307,10 +2376,28 @@ export function _hwfitInit() {
|
||||
uiModule.showToast('SSH setup command copied');
|
||||
});
|
||||
}
|
||||
_wireServerColorPicker(entry);
|
||||
entry.querySelectorAll('input, select').forEach(el => {
|
||||
el.addEventListener('change', () => {
|
||||
const selectedBefore = _envState.remoteHost || '';
|
||||
const entryHost = entry.querySelector('.cookbook-srv-host')?.value?.trim() || '';
|
||||
const color = entry.querySelector('.cookbook-srv-color')?.value?.trim() || '';
|
||||
const hasColor = /^#[0-9a-fA-F]{6}$/.test(color);
|
||||
const colorWrap = entry.querySelector('.cookbook-srv-color-wrap');
|
||||
if (hasColor) {
|
||||
entry.style.setProperty('--cookbook-server-color', color);
|
||||
colorWrap?.style.setProperty('--cookbook-server-color', color);
|
||||
} else {
|
||||
const autoColor = (colorWrap?.style.getPropertyValue('--cookbook-server-color') || entry.style.getPropertyValue('--cookbook-server-color') || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(autoColor)) {
|
||||
entry.style.setProperty('--cookbook-server-color', autoColor);
|
||||
colorWrap?.style.setProperty('--cookbook-server-color', autoColor);
|
||||
} else {
|
||||
entry.style.removeProperty('--cookbook-server-color');
|
||||
colorWrap?.style.removeProperty('--cookbook-server-color');
|
||||
}
|
||||
}
|
||||
colorWrap?.classList.toggle('has-color', true);
|
||||
_syncServers();
|
||||
_rebuildServerSelect();
|
||||
if (selectedBefore && selectedBefore === entryHost) {
|
||||
@@ -2320,6 +2407,11 @@ export function _hwfitInit() {
|
||||
if (!entry.querySelector('.cookbook-server-key-panel')?.classList.contains('hidden')) {
|
||||
_populateServerKeyPanel(entry, false);
|
||||
}
|
||||
const saveBtn = entry.querySelector('.cookbook-server-save-btn.saved');
|
||||
if (saveBtn) {
|
||||
saveBtn.classList.remove('saved');
|
||||
saveBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save';
|
||||
}
|
||||
});
|
||||
});
|
||||
// Manual connectivity test after editing host or port. Existing saved
|
||||
@@ -2341,7 +2433,7 @@ export function _hwfitInit() {
|
||||
_hwfitFetch();
|
||||
});
|
||||
}
|
||||
// Save button on a brand-new server entry: persist + confirm with a check.
|
||||
// Save button: persist + confirm with a check.
|
||||
const saveBtn = entry.querySelector('.cookbook-server-save-btn');
|
||||
if (saveBtn && !saveBtn.dataset.bound) {
|
||||
saveBtn.dataset.bound = '1';
|
||||
@@ -2359,6 +2451,7 @@ export function _hwfitInit() {
|
||||
} catch (_) {}
|
||||
saveBtn.classList.add('saved');
|
||||
saveBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><polyline points="20 6 9 17 4 12"/></svg>Saved';
|
||||
uiModule.showToast('Server saved');
|
||||
});
|
||||
}
|
||||
const rmBtn = entry.querySelector('.cookbook-server-rm');
|
||||
@@ -2520,7 +2613,7 @@ export function _hwfitInit() {
|
||||
// Build the new entry with the SAME template as existing servers (Model
|
||||
// Directory header, default checkmark, platform icon) \u2014 isNew swaps the
|
||||
// delete button for a Save button. forceRemote keeps it editable.
|
||||
const blank = { host: '', name: '', port: '', env: 'none', envPath: '', platform: '', modelDirs: ['~/.cache/huggingface/hub'] };
|
||||
const blank = { host: '', name: '', port: '', env: 'none', envPath: '', color: '', platform: '', modelDirs: ['~/.cache/huggingface/hub'] };
|
||||
const wrap = document.createElement('div');
|
||||
wrap.innerHTML = _serverEntryHtml(blank, idx, _envState.defaultServer || '', true, true);
|
||||
const entry = wrap.firstElementChild;
|
||||
@@ -2554,6 +2647,7 @@ export function _hwfitInit() {
|
||||
}
|
||||
}
|
||||
_persistEnvState();
|
||||
_applyServerSelectColor(serverSelect);
|
||||
// Keep the other server dropdowns (Download / Cache / Deps) in sync. The
|
||||
// download-input button reads #hwfit-dl-server *directly*, so without this
|
||||
// it kept its old value and downloads went to the wrong host even
|
||||
@@ -2561,6 +2655,7 @@ export function _hwfitInit() {
|
||||
document.querySelectorAll('#hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
|
||||
if (!sel || sel.tagName !== 'SELECT') return;
|
||||
sel.value = _currentServerValue();
|
||||
_applyServerSelectColor(sel);
|
||||
});
|
||||
_hwfitCache = null;
|
||||
// Reset GPU-toggle state (no flicker) so the new server's hardware re-renders.
|
||||
@@ -2568,5 +2663,6 @@ export function _hwfitInit() {
|
||||
_hwfitFetch();
|
||||
});
|
||||
}
|
||||
_syncServerSelectColors();
|
||||
|
||||
}
|
||||
|
||||
+276
-43
@@ -60,6 +60,11 @@ if (typeof window !== 'undefined' && !window._tagScrollGuardWired) {
|
||||
export const _MODELDIR_CHECK_OFF = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
|
||||
export const _MODELDIR_CHECK_ON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>';
|
||||
|
||||
function _normalizeCookbookModelDir(dir) {
|
||||
const d = String(dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
|
||||
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
|
||||
}
|
||||
|
||||
// Monochrome platform glyphs (currentColor) for a server's OS tag: a penguin for
|
||||
// Linux, the four-pane logo for Windows, an Android robot for Termux/Android.
|
||||
function _platformIcon(platform) {
|
||||
@@ -170,6 +175,92 @@ function _gemma4ThinkingChatTemplateArg(modelName) {
|
||||
: '';
|
||||
}
|
||||
|
||||
const _SERVER_COLOR_CHOICES = [
|
||||
['', 'Auto'],
|
||||
['#bd93f9', 'Purple'],
|
||||
['#ff79c6', 'Pink'],
|
||||
['#fca5a5', 'Red'],
|
||||
['#93c5fd', 'Blue'],
|
||||
['#86efac', 'Green'],
|
||||
['#d6b37a', 'Bronze'],
|
||||
['#111827', 'Black'],
|
||||
['#f8fafc', 'White'],
|
||||
['#c0c4cc', 'Silver'],
|
||||
['#d9f99d', 'Lime'],
|
||||
['#ccfbf1', 'Mint'],
|
||||
];
|
||||
const _SERVER_AUTO_COLOR_VALUES = _SERVER_COLOR_CHOICES.slice(1).map(([value]) => value);
|
||||
|
||||
function _serverColorValue(value) {
|
||||
const v = String(value || '').trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(v) ? v : '';
|
||||
}
|
||||
|
||||
function _serverColor(s) {
|
||||
return _serverColorValue(s && s.color);
|
||||
}
|
||||
|
||||
function _serverColorLabel(color) {
|
||||
const hit = _SERVER_COLOR_CHOICES.find(([value]) => value === color);
|
||||
return hit ? hit[1] : 'Auto';
|
||||
}
|
||||
|
||||
function _autoServerColor(index) {
|
||||
const servers = Array.isArray(_envState.servers) ? _envState.servers : [];
|
||||
const explicit = new Set(servers.map(s => _serverColor(s)).filter(Boolean));
|
||||
const used = new Set(explicit);
|
||||
for (let j = 0; j <= index; j++) {
|
||||
const s = servers[j] || {};
|
||||
const explicitColor = _serverColor(s);
|
||||
if (explicitColor) continue;
|
||||
const picked = _SERVER_AUTO_COLOR_VALUES.find(c => !used.has(c)) || _SERVER_AUTO_COLOR_VALUES[j % _SERVER_AUTO_COLOR_VALUES.length] || '';
|
||||
if (j === index) return picked;
|
||||
if (picked) used.add(picked);
|
||||
}
|
||||
return _SERVER_AUTO_COLOR_VALUES[index % _SERVER_AUTO_COLOR_VALUES.length] || '';
|
||||
}
|
||||
|
||||
function _resolvedServerColor(s, index) {
|
||||
return _serverColor(s) || _autoServerColor(index);
|
||||
}
|
||||
|
||||
function _serverOptionLabel(label, color) {
|
||||
return color ? `● ${label}` : label;
|
||||
}
|
||||
|
||||
function _serverOptionStyle(color) {
|
||||
return color ? ` style="color:${esc(color)};"` : '';
|
||||
}
|
||||
|
||||
function _serverColorOptionStyle(color) {
|
||||
if (!color) return ' style="background:var(--bg);color:var(--fg);"';
|
||||
const c = String(color).toLowerCase();
|
||||
const fg = (c === '#111827') ? '#f8fafc' : (c === '#f8fafc' || c === '#fca5a5' || c === '#93c5fd' || c === '#86efac' || c === '#d9f99d' || c === '#ccfbf1' || c === '#c0c4cc') ? '#111827' : color;
|
||||
return ` style="color:${esc(fg)};background-color:color-mix(in srgb, ${esc(color)} 28%, var(--bg));"`;
|
||||
}
|
||||
|
||||
function _serverColorForValue(value) {
|
||||
const s = _serverByVal(value);
|
||||
const idx = Array.isArray(_envState.servers) ? _envState.servers.indexOf(s) : -1;
|
||||
return s ? _resolvedServerColor(s, idx >= 0 ? idx : 0) : '';
|
||||
}
|
||||
|
||||
export function _applyServerSelectColor(sel) {
|
||||
if (!sel || sel.tagName !== 'SELECT') return;
|
||||
const color = _serverColorForValue(sel.value);
|
||||
if (color) {
|
||||
sel.style.setProperty('--cookbook-server-color', color);
|
||||
sel.classList.add('cookbook-server-select-colored');
|
||||
} else {
|
||||
sel.style.removeProperty('--cookbook-server-color');
|
||||
sel.classList.remove('cookbook-server-select-colored');
|
||||
}
|
||||
}
|
||||
|
||||
export function _syncServerSelectColors(root = document) {
|
||||
root.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(_applyServerSelectColor);
|
||||
}
|
||||
|
||||
function _buildServerOpts(excludeLocal = false) {
|
||||
// The local server is ALWAYS represented by the synthetic value="local" option
|
||||
// (showing its custom name from the "server name" feature). We must therefore
|
||||
@@ -177,7 +268,8 @@ function _buildServerOpts(excludeLocal = false) {
|
||||
const _localIdx = _envState.servers.findIndex(_isLocalEntry);
|
||||
const _localSrv = _localIdx >= 0 ? _envState.servers[_localIdx] : null;
|
||||
const _localLabel = (_localSrv && _localSrv.name) ? _localSrv.name : 'Local';
|
||||
let html = `<option value="local"${!_envState.remoteHost ? ' selected' : ''}>${esc(_localLabel)}</option>`;
|
||||
const _localColor = _localSrv ? _resolvedServerColor(_localSrv, _localIdx) : '';
|
||||
let html = `<option value="local"${_serverOptionStyle(_localColor)}${!_envState.remoteHost ? ' selected' : ''}>${esc(_serverOptionLabel(_localLabel, _localColor))}</option>`;
|
||||
const selectedKey = _envState.remoteServerKey || '';
|
||||
let legacyHostSelected = false;
|
||||
for (let i = 0; i < _envState.servers.length; i++) {
|
||||
@@ -186,12 +278,13 @@ function _buildServerOpts(excludeLocal = false) {
|
||||
if (excludeLocal && _isLocalEntry(s)) continue;
|
||||
const label = s.name || s.host || `Server ${i + 1}`;
|
||||
const value = _serverKey(s);
|
||||
const color = _resolvedServerColor(s, i);
|
||||
let selected = selectedKey ? value === selectedKey : false;
|
||||
if (!selectedKey && _envState.remoteHost === s.host && !legacyHostSelected) {
|
||||
selected = true;
|
||||
legacyHostSelected = true;
|
||||
}
|
||||
html += `<option value="${esc(value)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
|
||||
html += `<option value="${esc(value)}"${_serverOptionStyle(color)}${selected ? ' selected' : ''}>${esc(_serverOptionLabel(label, color))}</option>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
@@ -345,6 +438,8 @@ export function _detectReasoningParser(modelName) {
|
||||
// MiniMax M2 / M2.5 / M2.7 — released with a dedicated parser. Catch M2
|
||||
// before plain "minimax" so M2.x doesn't fall through to a wrong parser.
|
||||
if (n.includes('minimax') && n.match(/\bm2(?:\.\d)?\b/)) return 'minimax_m2';
|
||||
// DeepSeek-V4 has a dedicated parser in SGLang. Keep it before R1/V3.
|
||||
if (n.includes('deepseek') && /\bv[-_]?4\b/.test(n)) return 'deepseek-v4';
|
||||
// DeepSeek-R1 / V3-Thinking / V3.1-Thinking variants. Bare V3/V3.1 (non-
|
||||
// thinking) skip this — they're not reasoning models.
|
||||
if (n.includes('deepseek') && (n.includes('r1') || n.includes('thinking'))) return 'deepseek_r1';
|
||||
@@ -382,6 +477,7 @@ export function _detectToolParser(modelName) {
|
||||
if (n.includes('llama-4') || n.includes('llama4')) return 'llama4_json';
|
||||
if (n.includes('llama') || n.includes('nemotron')) return 'llama3_json';
|
||||
if (n.includes('mistral') || n.includes('mixtral')) return 'mistral';
|
||||
if (n.includes('deepseek') && /\bv[-_]?4\b/.test(n)) return 'deepseekv4';
|
||||
if (n.includes('deepseek-v3')) return 'deepseek_v3';
|
||||
if (n.includes('deepseek')) return 'deepseek_v3';
|
||||
if (n.includes('minimax') && /\bm3\b/.test(n)) return 'minimax_m3';
|
||||
@@ -409,7 +505,7 @@ export function _detectBackend(model) {
|
||||
const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend);
|
||||
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
|
||||
if (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX')) {
|
||||
return { backend: 'unsupported', label: 'Unsupported' };
|
||||
return { backend: 'mlx', label: 'MLX' };
|
||||
}
|
||||
const isAwqLike = /^AWQ|^GPTQ|^NVFP4/.test(q) || ['FP8', 'FP4', 'MXFP4', 'NF4', 'INT4', 'INT8', 'W4A16', 'W8A8', 'W8A16'].includes(q) || /\b(awq|gptq|fp8|fp4|nvfp4|mxfp4|nf4|int4|int8|w4a16|w8a8|w8a16)\b/i.test(_nm);
|
||||
const hasGgufFile = Array.isArray(model.gguf_files)
|
||||
@@ -442,7 +538,7 @@ export function _detectBackend(model) {
|
||||
// don't run on macOS; vLLM-native quantized models are already filtered out
|
||||
// of metal Cookbook results, so llama.cpp is always the right engine here.
|
||||
if (['metal', 'mps', 'apple'].includes(sysBackend)) {
|
||||
return { backend: 'llamacpp', label: 'llama.cpp' };
|
||||
return { backend: 'mlx', label: 'MLX' };
|
||||
}
|
||||
|
||||
// ROCm/AMD machines should not blindly default HF safetensors models to
|
||||
@@ -540,13 +636,32 @@ function _venvRootFromPath(path) {
|
||||
return p;
|
||||
}
|
||||
|
||||
function _venvLooksWrongForPlatform(path, platform) {
|
||||
const p = String(path || '').trim();
|
||||
const plat = String(platform || '').toLowerCase();
|
||||
if (!p || !plat) return false;
|
||||
if ((plat === 'darwin' || plat === 'macos') && /^\/(?:home|usr\/local\/cuda|opt\/conda)\//.test(p)) return true;
|
||||
if ((plat === 'linux' || plat === 'termux') && /^\/(?:Users|opt\/homebrew)\//.test(p)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function _isDeepSeekV4Model(modelName) {
|
||||
const n = String(modelName || '').toLowerCase();
|
||||
return n.includes('deepseek') && /\bv[-_]?4\b/.test(n);
|
||||
}
|
||||
|
||||
function _envHasKey(envText, key) {
|
||||
return String(envText || '').split(/\s+/).some(part => part.startsWith(`${key}=`));
|
||||
}
|
||||
|
||||
export function _buildServeCmd(f, modelName, backend) {
|
||||
// When a venv is configured on the chosen server, use the venv's binaries
|
||||
// by absolute path. Bare `vllm` / `python3` relies on PATH, and SSH non-
|
||||
// interactive sessions often leave a user-site install (~/.local/bin/vllm)
|
||||
// ahead of the venv's bin, so the WRONG vllm gets launched even with the
|
||||
// venv activated. Absolute path sidesteps the whole PATH question.
|
||||
const _formVenv = (f.venv ?? '').toString().trim();
|
||||
let _formVenv = (f.venv ?? '').toString().trim();
|
||||
if (_venvLooksWrongForPlatform(_formVenv, f.platform)) _formVenv = '';
|
||||
const _activeVenvPath = _venvRootFromPath(_formVenv || (_envState.env === 'venv' ? (_envState.envPath || '') : ''));
|
||||
const _venvBin = _activeVenvPath ? (_activeVenvPath + '/bin/') : '';
|
||||
const _vllmBin = _venvBin ? `${_venvBin}vllm` : 'vllm';
|
||||
@@ -618,14 +733,19 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
// button strip is the only source for which devices to pin.
|
||||
const gpuId = (f.gpus || f.gpu_id || '').toString().trim();
|
||||
cmd += _gpuEnvPrefix(gpuId);
|
||||
const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim();
|
||||
const _isDsv4 = _isDeepSeekV4Model(modelName);
|
||||
let _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim();
|
||||
if (_isDsv4 && !_envHasKey(_extraEnv, 'SGLANG_DSV4_COMPRESS_STATE_DTYPE')) {
|
||||
_extraEnv = (`SGLANG_DSV4_COMPRESS_STATE_DTYPE=bf16 ${_extraEnv}`).trim();
|
||||
}
|
||||
if (_extraEnv) cmd += _extraEnv + ' ';
|
||||
cmd += `${_py3Bin} -m sglang.launch_server --model-path ${modelName} --host 0.0.0.0 --port ${f.port || '30000'}`;
|
||||
const _gemma4ChatTemplate = _gemma4ThinkingChatTemplateArg(modelName);
|
||||
if (_gemma4ChatTemplate) cmd += ` --chat-template ${_gemma4ChatTemplate}`;
|
||||
if (f.tp && f.tp !== '1') cmd += ` --tp ${f.tp}`;
|
||||
if (f.ctx) cmd += ` --context-length ${f.ctx}`;
|
||||
if (f.gpu_mem && f.gpu_mem !== '0.90') cmd += ` --mem-fraction-static ${f.gpu_mem}`;
|
||||
const _memFraction = _isDsv4 && (!f.gpu_mem || f.gpu_mem === '0.90') ? '0.80' : f.gpu_mem;
|
||||
if (_memFraction && _memFraction !== '0.90') cmd += ` --mem-fraction-static ${_memFraction}`;
|
||||
if (f.dtype && f.dtype !== 'auto') cmd += ` --dtype ${f.dtype}`;
|
||||
if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-running-requests ${f.max_seqs.toString().trim()}`;
|
||||
if (f.trust_remote) cmd += ' --trust-remote-code';
|
||||
@@ -638,6 +758,14 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
}
|
||||
if (!f.prefix_cache) cmd += ' --disable-radix-cache';
|
||||
if (f.enforce_eager) cmd += ' --disable-cuda-graph';
|
||||
const _decodeGraph = String(f.sglang_decode_graph || '').trim();
|
||||
if (!f.enforce_eager && _decodeGraph === 'disabled') {
|
||||
cmd += ' --cuda-graph-backend-decode disabled';
|
||||
} else if (!f.enforce_eager && _decodeGraph === 'bs16') {
|
||||
cmd += ' --cuda-graph-max-bs-decode 16';
|
||||
} else if (!f.enforce_eager && _isDsv4 && !/\s--cuda-graph-max-bs-decode\b/.test(cmd) && !/\s--cuda-graph-backend-decode\b/.test(cmd)) {
|
||||
cmd += ' --cuda-graph-backend-decode disabled';
|
||||
}
|
||||
} else if (backend === 'llamacpp') {
|
||||
const ggufPath = f._gguf_path || 'model.gguf';
|
||||
// GPU list — read from gpus (button strip); fall back to gpu_id for
|
||||
@@ -812,6 +940,13 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
if (f.diff_attention_slicing) cmd += ' --attention-slicing';
|
||||
if (f.diff_vae_slicing) cmd += ' --vae-slicing';
|
||||
if (f.diff_harmonize_gpu) cmd += ` --harmonize-gpu ${f.diff_harmonize_gpu}`;
|
||||
} else if (backend === 'mlx') {
|
||||
const mlxPy = _isWindows() ? 'python' : _py3Bin;
|
||||
const mlxHost = f.host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd += `${mlxPy} -m mlx_lm.server --model ${_shellQuote(modelName)} --host ${mlxHost} --port ${f.port || '8080'}`;
|
||||
if (/minimax|mini-max/i.test(modelName)) {
|
||||
cmd += ' --temp 0.7 --top-p 0.9 --max-tokens 2048';
|
||||
}
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
@@ -933,6 +1068,7 @@ async function _fetchDependencies() {
|
||||
const pkgs = data.packages || [];
|
||||
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; }
|
||||
const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']);
|
||||
const _systemInstallable = new Set(['tmux']);
|
||||
|
||||
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
|
||||
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
|
||||
@@ -944,8 +1080,12 @@ async function _fetchDependencies() {
|
||||
if (pkg.installed) return `<button class="cookbook-dep-tag cookbook-dep-installed cookbook-dep-installed-btn" title="Installed — click for actions"><span class="cookbook-dep-installed-label">Installed</span><span class="cookbook-dep-caret">▾</span></button>`;
|
||||
if (isSystemDep) {
|
||||
const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.');
|
||||
if (pkg.applicable !== false && _systemInstallable.has(pkg.name)) {
|
||||
return `<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-install-sysdeps" data-dep-sysdeps="${esc(pkg.name)}" data-dep-target="${isLocal ? 'local' : 'remote'}" title="${depTip}">Install</button>`;
|
||||
}
|
||||
const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing';
|
||||
return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}">${depLabel}</span>`;
|
||||
const depStyle = pkg.name === 'docker' ? ' style="width:87.7px;justify-content:center;"' : '';
|
||||
return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}"${depStyle}>${depLabel}</span>`;
|
||||
}
|
||||
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
|
||||
};
|
||||
@@ -957,6 +1097,7 @@ async function _fetchDependencies() {
|
||||
const _DEP_GLYPHS = {
|
||||
vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:13px;height:13px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
mlx_lm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
|
||||
llama_cpp: '<svg width="13" height="13" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
|
||||
ollama: '<img src="/static/icons/ollama-mark-crop.png" alt="" aria-hidden="true" width="13" height="13" style="display:block;width:13px;height:13px;object-fit:contain;" />',
|
||||
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
@@ -1121,22 +1262,32 @@ async function _fetchDependencies() {
|
||||
// "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U;
|
||||
// `statusEl`, when given, shows "Installing…/Updating…" and is disabled.
|
||||
async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl) {
|
||||
let targetServer = null;
|
||||
if (isLocalOnly) {
|
||||
_envState.remoteHost = '';
|
||||
_envState.env = 'none';
|
||||
_envState.envPath = '';
|
||||
} else {
|
||||
const depsServerSel = document.getElementById('hwfit-deps-server');
|
||||
if (depsServerSel) _applyServerSelection(depsServerSel.value);
|
||||
if (depsServerSel) {
|
||||
targetServer = _serverByVal(depsServerSel.value);
|
||||
_applyServerSelection(depsServerSel.value);
|
||||
}
|
||||
}
|
||||
const targetHost = isLocalOnly ? 'this server' : (_envState.remoteHost || 'local');
|
||||
const targetHost = isLocalOnly ? 'this server' : ((targetServer?.host || _envState.remoteHost) || 'local');
|
||||
const targetEnv = isLocalOnly ? 'none' : (targetServer?.env || _envState.env || 'none');
|
||||
const targetEnvPath = isLocalOnly ? '' : (targetServer?.envPath || _envState.envPath || '');
|
||||
const targetPlatform = isLocalOnly ? (_envState.hostPlatform || _envState.platform || '') : (targetServer?.platform || _envState.platform || '');
|
||||
const targetRemoteHost = isLocalOnly ? '' : (targetServer?.host || _envState.remoteHost || '');
|
||||
// Always go through `python -m pip` so the leading token is `python`
|
||||
// — matches the /api/model/serve allow-list (bare `pip` is blocked).
|
||||
// Inside a venv/conda env, `--user` is invalid (pip refuses), so we
|
||||
// only add `--user --break-system-packages` when there's no env —
|
||||
// for PEP-668-locked system pythons (Arch, newer Debian).
|
||||
const _inEnv = _envState.env === 'venv' || _envState.env === 'conda';
|
||||
const _pipFlags = (!_isWindows() && !_inEnv) ? ' --user --break-system-packages' : '';
|
||||
const _inEnv = targetEnv === 'venv' || targetEnv === 'conda';
|
||||
const _platform = String(targetPlatform || '').toLowerCase();
|
||||
const _isAppleTarget = _platform === 'darwin' || _platform === 'macos' || _platform.includes('mac os');
|
||||
const _pipFlags = (!_isWindows() && !_inEnv) ? (_isAppleTarget ? ' --user' : ' --user --break-system-packages') : '';
|
||||
// Use the venv's python3 by absolute path when configured. Even with the
|
||||
// env_prefix sourcing activate, SSH non-interactive sessions sometimes
|
||||
// pick a `python3` ahead of the venv's bin on PATH, so the install
|
||||
@@ -1144,35 +1295,35 @@ async function _fetchDependencies() {
|
||||
let _py;
|
||||
if (_isWindows()) {
|
||||
_py = 'python';
|
||||
} else if (_envState.env === 'venv' && _envState.envPath) {
|
||||
_py = `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`;
|
||||
} else if (targetEnv === 'venv' && targetEnvPath) {
|
||||
_py = `${targetEnvPath.replace(/\/+$/, '')}/bin/python3`;
|
||||
} else {
|
||||
_py = 'python3';
|
||||
}
|
||||
const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} "${pipName}"`;
|
||||
let envPrefix = '';
|
||||
if (_isWindows()) {
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
envPrefix = 'conda activate ' + _psQuote(_envState.envPath);
|
||||
if (targetEnv === 'venv' && targetEnvPath) {
|
||||
envPrefix = '& ' + _psQuote(targetEnvPath.endsWith('\\Scripts\\Activate.ps1') ? targetEnvPath : targetEnvPath + '\\Scripts\\Activate.ps1');
|
||||
} else if (targetEnv === 'conda' && targetEnvPath) {
|
||||
envPrefix = 'conda activate ' + _psQuote(targetEnvPath);
|
||||
}
|
||||
} else {
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
const p = _envState.envPath;
|
||||
if (targetEnv === 'venv' && targetEnvPath) {
|
||||
const p = targetEnvPath;
|
||||
envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
|
||||
} else if (targetEnv === 'conda' && targetEnvPath) {
|
||||
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(targetEnvPath);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const reqBody = {
|
||||
repo_id: pipName,
|
||||
cmd: cmd,
|
||||
remote_host: _envState.remoteHost || undefined,
|
||||
ssh_port: _getPort(_envState.remoteHost) || undefined,
|
||||
remote_host: targetRemoteHost || undefined,
|
||||
ssh_port: _getPort(targetRemoteHost) || undefined,
|
||||
env_prefix: envPrefix || undefined,
|
||||
platform: _envState.platform || undefined,
|
||||
platform: targetPlatform || undefined,
|
||||
};
|
||||
const res = await fetch('/api/model/serve', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
@@ -1196,7 +1347,7 @@ async function _fetchDependencies() {
|
||||
}
|
||||
// _dep flags this as a pip dependency/driver install (not a servable
|
||||
// model) so the running-task card doesn't offer a "Serve →" button.
|
||||
const payload = { repo_id: pipName, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true, env_path: _envState.envPath || '' };
|
||||
const payload = { repo_id: pipName, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' };
|
||||
_addTask(data.session_id, 'pip ' + pkgName, 'download', payload);
|
||||
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
|
||||
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
|
||||
@@ -1334,7 +1485,7 @@ async function _fetchDependencies() {
|
||||
// from the row) so the user can copy-paste it without leaving
|
||||
// the toast. Otherwise just surface the error.
|
||||
const _suffix = _resolvedCmd ? `\n\nRun on ${targetLabel}: ${_resolvedCmd}` : '';
|
||||
uiModule.showToast('Build-deps install failed: ' + String(reason).slice(0, 300) + _suffix, {
|
||||
uiModule.showToast('System dependency install failed: ' + String(reason).slice(0, 300) + _suffix, {
|
||||
duration: 25000,
|
||||
action: _resolvedCmd ? 'Copy command' : 'OK',
|
||||
onAction: async () => {
|
||||
@@ -1638,9 +1789,47 @@ function _applyServerSelection(val) {
|
||||
sel.value = _want;
|
||||
if (sel.selectedIndex < 0) sel.value = 'local';
|
||||
}
|
||||
_applyServerSelectColor(sel);
|
||||
});
|
||||
}
|
||||
|
||||
async function _refreshScanDownloadTarget() {
|
||||
const btn = document.getElementById('hwfit-hw-refresh-btn');
|
||||
if (btn && btn.disabled) return;
|
||||
const selectedVal = document.getElementById('hwfit-server-select')?.value || _currentServerValue();
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = '0.55';
|
||||
btn.style.cursor = 'wait';
|
||||
}
|
||||
try {
|
||||
if (selectedVal) _applyServerSelection(selectedVal);
|
||||
const ok = await _syncFromServer().catch((e) => {
|
||||
console.warn('[cookbook] explicit server sync failed', e);
|
||||
return false;
|
||||
});
|
||||
if (ok) {
|
||||
try { Object.assign(_envState, _readStoredEnvState()); } catch {}
|
||||
if (selectedVal) _applyServerSelection(selectedVal);
|
||||
}
|
||||
_resetGpuToggleState();
|
||||
await Promise.allSettled([
|
||||
_hwfitFetch(true),
|
||||
_fetchCachedModels(true),
|
||||
]);
|
||||
if (uiModule?.showToast) uiModule.showToast('Refreshed selected server');
|
||||
} catch (e) {
|
||||
console.warn('[cookbook] scan/download refresh failed', e);
|
||||
if (uiModule?.showError) uiModule.showError('Refresh failed: ' + (e?.message || e));
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = '';
|
||||
btn.style.cursor = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _wireTabEvents(body) {
|
||||
// Tab switching
|
||||
body.querySelectorAll('.cookbook-tab').forEach(tab => {
|
||||
@@ -1701,17 +1890,18 @@ function _wireTabEvents(body) {
|
||||
const port = entry.querySelector('.cookbook-srv-port')?.value?.trim() || '';
|
||||
const env = entry.querySelector('.cookbook-srv-env')?.value || 'none';
|
||||
const envPath = entry.querySelector('.cookbook-srv-path')?.value?.trim() || '';
|
||||
const color = _serverColorValue(entry.querySelector('.cookbook-srv-color')?.value || '');
|
||||
const platform = entry.dataset.platform || '';
|
||||
const dirs = [];
|
||||
entry.querySelectorAll('.cookbook-modeldir-tag').forEach(tag => {
|
||||
// Read from data attribute (authoritative) — never parse displayed text
|
||||
const d = (tag.dataset.dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
|
||||
const d = _normalizeCookbookModelDir(tag.dataset.dir || '');
|
||||
if (d) dirs.push(d);
|
||||
});
|
||||
// Directory flagged as the download target ('' = default HF cache).
|
||||
const dlEl = entry.querySelector('.cookbook-modeldir-dl.active');
|
||||
const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : '';
|
||||
servers.push({ name, host, port, env, envPath, modelDirs: dirs, downloadDir, platform });
|
||||
servers.push({ name, host, port, env, envPath, color, modelDirs: dirs, downloadDir, platform });
|
||||
});
|
||||
_envState.servers = servers;
|
||||
// Auto-default: when the user has configured EXACTLY ONE remote server
|
||||
@@ -1737,13 +1927,16 @@ function _wireTabEvents(body) {
|
||||
Promise.resolve().then(() => {
|
||||
const _want = _currentServerValue();
|
||||
document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
|
||||
if (sel && sel.tagName === 'SELECT') sel.value = _want;
|
||||
if (sel && sel.tagName === 'SELECT') {
|
||||
sel.value = _want;
|
||||
_applyServerSelectColor(sel);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Wire server form inputs
|
||||
document.querySelectorAll('.cookbook-srv-name, .cookbook-srv-host, .cookbook-srv-port, .cookbook-srv-path').forEach(el => {
|
||||
document.querySelectorAll('.cookbook-srv-name, .cookbook-srv-color, .cookbook-srv-host, .cookbook-srv-port, .cookbook-srv-path').forEach(el => {
|
||||
el.addEventListener('change', _syncServers);
|
||||
});
|
||||
document.querySelectorAll('.cookbook-srv-env').forEach(el => {
|
||||
@@ -1788,7 +1981,7 @@ function _wireTabEvents(body) {
|
||||
if (cacheDirEl) cacheDirEl.value = srv.modelDir || '~/.cache/huggingface/hub';
|
||||
const dirsEl = document.querySelector('.cookbook-serve-dirs');
|
||||
if (dirsEl) {
|
||||
const dirs = (Array.isArray(srv.modelDirs) ? srv.modelDirs : [srv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
|
||||
const dirs = (Array.isArray(srv.modelDirs) ? srv.modelDirs : [srv.modelDir || '~/.cache/huggingface/hub']).map(d => _normalizeCookbookModelDir(d)).filter(Boolean);
|
||||
dirsEl.innerHTML = dirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('') +
|
||||
'<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>';
|
||||
dirsEl.querySelector('.cookbook-serve-dir-edit')?.addEventListener('click', () => {
|
||||
@@ -1802,7 +1995,22 @@ function _wireTabEvents(body) {
|
||||
|
||||
const scanBtn = document.getElementById('hwfit-cache-scan');
|
||||
if (scanBtn) {
|
||||
scanBtn.addEventListener('click', () => _fetchCachedModels(true));
|
||||
scanBtn.addEventListener('click', async () => {
|
||||
if (scanBtn.disabled) return;
|
||||
scanBtn.disabled = true;
|
||||
scanBtn.classList.add('spinning');
|
||||
try {
|
||||
await _fetchCachedModels(true);
|
||||
} finally {
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.classList.remove('spinning');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hwRefreshBtn = document.getElementById('hwfit-hw-refresh-btn');
|
||||
if (hwRefreshBtn) {
|
||||
hwRefreshBtn.addEventListener('click', _refreshScanDownloadTarget);
|
||||
}
|
||||
|
||||
const editDirsLink = document.querySelector('.cookbook-serve-dir-edit');
|
||||
@@ -1822,6 +2030,7 @@ function _wireTabEvents(body) {
|
||||
_fetchDependencies();
|
||||
});
|
||||
}
|
||||
_syncServerSelectColors(body);
|
||||
|
||||
// "Rebuild llama.cpp" clears the cached build so the next serve recompiles.
|
||||
// The serve bootstrap only builds llama-server when it is missing from PATH,
|
||||
@@ -2522,11 +2731,30 @@ function _wireTabEvents(body) {
|
||||
// (Model Directory header, default-server checkmark, trash delete, platform icon).
|
||||
// forceRemote renders an editable remote entry even before a host is typed
|
||||
// (a new server's host is empty, which would otherwise read as "Local").
|
||||
export function _serverDefaultHtml(active) {
|
||||
const check = active ? '<span class="hwfit-hf-check cookbook-srv-default-check" title="Default server" style="font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;">✓</span>' : '';
|
||||
return `${check}<span class="cookbook-srv-default-label">default</span>`;
|
||||
}
|
||||
|
||||
export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
|
||||
const isLocal = (forceRemote || isNew) ? false : (!s.host || s.host === 'local');
|
||||
const envOpts = [['none', 'None'], ['venv', 'venv'], ['conda', 'conda']].map(([value, label]) => `<option value="${value}"${s.env === value ? ' selected' : ''}>${label}</option>`).join('');
|
||||
const srvColor = _serverColor(s);
|
||||
const resolvedSrvColor = _resolvedServerColor(s, i);
|
||||
const colorOpts = _SERVER_COLOR_CHOICES.map(([value, label]) => {
|
||||
const displayLabel = label;
|
||||
return `<option value="${esc(value)}"${_serverColorOptionStyle(value)}${srvColor === value ? ' selected' : ''}>${esc(displayLabel)}</option>`;
|
||||
}).join('');
|
||||
const selectedColorLabel = srvColor ? _serverColorLabel(srvColor) : `Auto · ${_serverColorLabel(resolvedSrvColor)}`;
|
||||
const colorMenu = _SERVER_COLOR_CHOICES.map(([value, label]) => {
|
||||
const active = value === srvColor;
|
||||
const swatchColor = value || resolvedSrvColor;
|
||||
const rowLabel = value ? label : `Auto · ${_serverColorLabel(resolvedSrvColor)}`;
|
||||
const swatch = swatchColor ? ` style="--swatch-color:${esc(swatchColor)};"` : '';
|
||||
return `<button type="button" class="cookbook-srv-color-item${active ? ' active' : ''}" data-color="${esc(value)}"${swatch}><span class="cookbook-srv-color-item-dot"></span><span>${esc(rowLabel)}</span></button>`;
|
||||
}).join('');
|
||||
let html = '';
|
||||
html += `<div class="cookbook-server-entry" data-idx="${i}" data-platform="${esc(s.platform || '')}">`;
|
||||
html += `<div class="cookbook-server-entry" data-idx="${i}" data-platform="${esc(s.platform || '')}"${resolvedSrvColor ? ` style="--cookbook-server-color:${esc(resolvedSrvColor)};"` : ''}>`;
|
||||
const _srvTitle = s.name || (isLocal ? 'Local' : (s.host || `Server ${i + 1}`));
|
||||
const _srvKey = isLocal ? 'local' : (s.host || '');
|
||||
const _isDefaultSrv = (defaultServer || '') === _srvKey;
|
||||
@@ -2542,12 +2770,13 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
|
||||
// sense once the server is saved.
|
||||
html += `<span style="margin-left:auto;display:inline-flex;gap:4px;align-items:center;">${_checkBtn}${_keyBtn}<button class="cookbook-server-cancel-btn" title="Discard this new server" style="height:22px;box-sizing:border-box;display:inline-flex;align-items:center;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Cancel</button></span>`;
|
||||
} else {
|
||||
html += `<span style="margin-left:auto;display:inline-flex;gap:4px;align-items:center;">${!isLocal ? _checkBtn + _keyBtn : ''}<span class="cookbook-srv-default${_isDefaultSrv ? ' active' : ''}" title="${_isDefaultSrv ? 'Default server — Cookbook opens here' : 'Make this the default server'}" data-srv-key="${esc(_srvKey)}">${_isDefaultSrv ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF}<span class="cookbook-srv-default-label">default</span></span></span>`;
|
||||
html += `<span style="margin-left:auto;display:inline-flex;gap:4px;align-items:center;">${!isLocal ? _checkBtn + _keyBtn : ''}<span class="cookbook-srv-default${_isDefaultSrv ? ' active' : ''}" title="${_isDefaultSrv ? 'Default server — Cookbook opens here' : 'Make this the default server'}" data-srv-key="${esc(_srvKey)}">${_serverDefaultHtml(_isDefaultSrv)}</span></span>`;
|
||||
}
|
||||
html += `</span>`;
|
||||
html += `<div class="cookbook-server-row">`;
|
||||
html += `<input type="text" class="hwfit-sf cookbook-srv-name" value="${esc(s.name || (isLocal ? 'Local' : ''))}" placeholder="Name (optional)" style="width:92px;flex-shrink:0;" />`;
|
||||
html += `<input type="text" class="hwfit-sf cookbook-srv-host" value="${isLocal ? '' : esc(s.host || '')}" placeholder="e.g. user@ip" style="width:214.5px;flex-shrink:0;box-sizing:border-box;" ${isLocal ? 'readonly' : ''} />`;
|
||||
html += `<span class="cookbook-srv-color-wrap has-color" title="Server color"><select class="hwfit-sf cookbook-srv-color" aria-hidden="true" tabindex="-1">${colorOpts}</select><button type="button" class="hwfit-sf cookbook-srv-color-btn" aria-haspopup="listbox" aria-expanded="false"><span class="cookbook-srv-color-dot" aria-hidden="true"></span><span class="cookbook-srv-color-label">${esc(selectedColorLabel)}</span><svg class="cookbook-srv-color-caret" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button><div class="cookbook-srv-color-menu hidden" role="listbox">${colorMenu}</div></span>`;
|
||||
html += `<input type="text" class="hwfit-sf cookbook-srv-host" value="${isLocal ? '' : esc(s.host || '')}" placeholder="e.g. user@ip" style="width:184px;flex-shrink:0;box-sizing:border-box;" ${isLocal ? 'readonly' : ''} />`;
|
||||
html += `<input type="text" class="hwfit-sf cookbook-srv-port" value="${esc(s.port || '')}" placeholder="Port" title="SSH port (default 22)" style="width:48px;flex-shrink:0;" ${isLocal ? 'readonly' : ''} />`;
|
||||
html += `<select class="hwfit-sf cookbook-srv-env">${envOpts}</select>`;
|
||||
html += `<input type="text" class="hwfit-sf cookbook-srv-path" value="${esc(s.envPath || '')}" placeholder="${s.platform === 'windows' ? 'venv/conda env' : '~/venv or conda-env'}" />`;
|
||||
@@ -2567,13 +2796,15 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
|
||||
html += `<span class="cookbook-modeldir-tag${isDefault ? ' cookbook-modeldir-default' : ''}${isTarget ? ' cookbook-modeldir-target' : ''}" data-dir-idx="${j}" data-dir="${esc(modelDirs[j])}">${dlBtn} ${esc(modelDirs[j])}${rmBtn}</span>`;
|
||||
}
|
||||
html += `<button class="cookbook-modeldir-add" title="Add model directory">+ Add</button>`;
|
||||
const _btnStyle = 'margin-left:auto;position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;';
|
||||
const _btnBaseStyle = 'position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;';
|
||||
const _btnPushStyle = `margin-left:auto;${_btnBaseStyle}`;
|
||||
if (isNew) {
|
||||
// A brand-new server: Save (confirm) sits where Delete would be; Cancel is
|
||||
// top-right in the title. Save confirms with a checkmark (auto-saves on edit too).
|
||||
html += `<button class="cookbook-server-save-btn" title="Save this server" style="${_btnStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
|
||||
html += `<button class="cookbook-server-save-btn" title="Save this server" style="${_btnPushStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
|
||||
} else if (!isLocal) {
|
||||
html += `<button class="cookbook-server-rm cookbook-server-rm-btn" title="Delete this server" style="${_btnStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>Delete</button>`;
|
||||
html += `<button class="cookbook-server-rm cookbook-server-rm-btn" title="Delete this server" style="${_btnPushStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>Delete</button>`;
|
||||
html += `<button class="cookbook-server-save-btn" title="Save server changes" style="${_btnBaseStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
if (!isLocal) {
|
||||
@@ -2709,6 +2940,7 @@ function _renderRecipes() {
|
||||
html += '<option value="">Engine</option>';
|
||||
html += '<option value="llamacpp">llama.cpp</option>';
|
||||
html += '<option value="ollama">Ollama</option>';
|
||||
html += '<option value="mlx">MLX</option>';
|
||||
html += '<option value="vllm">vLLM</option>';
|
||||
html += '<option value="sglang">SGLang</option>';
|
||||
html += '<option value="diffusers">Diffusers</option>';
|
||||
@@ -2726,7 +2958,7 @@ function _renderRecipes() {
|
||||
html += '<span class="hwfit-quant-wrap">';
|
||||
html += '<select class="cookbook-field-input hwfit-quant" id="hwfit-quant" style="height:28px;">';
|
||||
html += '<option value="" selected>Quant</option>';
|
||||
html += '<option value="Q4_K_M">Q4</option><option value="Q8_0">Q8</option>';
|
||||
html += '<option value="Q4_K_M">Q4 / AWQ</option><option value="Q8_0">Q8</option>';
|
||||
html += '<option value="Q6_K">Q6</option><option value="Q5_K_M">Q5</option>';
|
||||
html += '<option value="Q3_K_M">Q3</option><option value="Q2_K">Q2</option>';
|
||||
html += '<option value="AWQ-4bit">AWQ</option><option value="FP8">FP8</option><option value="FP4">FP4</option><option value="NVFP4">NVFP4</option></select>';
|
||||
@@ -2744,9 +2976,8 @@ function _renderRecipes() {
|
||||
html += _buildServerOpts(false);
|
||||
html += '</select>';
|
||||
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
|
||||
// (Rescan button removed — Edit handles manual hardware updates;
|
||||
// automatic re-probe runs on container restart.)
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-refresh-btn" id="hwfit-hw-refresh-btn" title="Refresh selected server hardware and cached models" aria-label="Refresh selected server hardware and cached models" style="flex-shrink:0;position:relative;top:-3px;left:-3px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
|
||||
// Sort state — the clickable column headers read/write this (pewds' original
|
||||
// sort paradigm). Newest is reachable by clicking the Model column header.
|
||||
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
|
||||
@@ -2787,7 +3018,7 @@ function _renderRecipes() {
|
||||
html += '<h2 style="margin:0;padding:0;line-height:1;">Serve <span id="serve-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>';
|
||||
html += '</div>';
|
||||
const _selSrv = _es.servers.find(s => s.host === _es.remoteHost) || _es.servers[0] || {};
|
||||
const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
|
||||
const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => _normalizeCookbookModelDir(d)).filter(Boolean);
|
||||
html += '<div class="cookbook-serve-dirs" style="margin-top:6px;">';
|
||||
html += _srvDirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('');
|
||||
html += '<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>';
|
||||
@@ -2797,6 +3028,7 @@ function _renderRecipes() {
|
||||
html += '<select class="memory-sort-select" id="serve-sort" style="height:24px;">';
|
||||
html += '<option value="name">Name</option><option value="size-desc">Size \u2193</option><option value="size-asc">Size \u2191</option><option value="recent">Recent</option>';
|
||||
html += '</select>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-cache-scan" title="Refresh cached models on selected server" aria-label="Refresh cached models on selected server"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
|
||||
html += '</div>';
|
||||
html += '<div class="memory-toolbar" style="margin-top:8px;">';
|
||||
html += '<div class="memory-category-filters">';
|
||||
@@ -3170,6 +3402,7 @@ document.addEventListener('cookbook:state-synced', () => {
|
||||
if (isVisible()) {
|
||||
const activeTab = document.querySelector('#cookbook-modal .cookbook-tab.active')?.dataset?.backend || '';
|
||||
if (activeTab === 'Running') _renderRunningTab();
|
||||
else if (activeTab === 'Serve') _rerenderCachedModels();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -484,8 +484,12 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
// they disagree on the active host. The servers LIST is consistent, so we look
|
||||
// up the matching server to get its env / path / platform / port.
|
||||
let host;
|
||||
let selectedServer = null;
|
||||
let selectedServerKey = '';
|
||||
if (hostOverride !== undefined) {
|
||||
host = hostOverride || '';
|
||||
selectedServer = host ? (_serverByVal?.(host) || (_envState.servers || []).find(s => s.host === host) || null) : null;
|
||||
selectedServerKey = selectedServer ? (typeof window.cookbookModule?._serverKey === 'function' ? window.cookbookModule._serverKey(selectedServer) : '') : '';
|
||||
} else {
|
||||
// No explicit host passed: resolve from the visible server dropdown rather
|
||||
// than _envState.remoteHost (unreliable — multiple state copies disagree).
|
||||
@@ -496,15 +500,21 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
const _dsrv = (_ssv && _ssv !== 'local') ? (_serverByVal?.(_ssv) || _envState.servers[parseInt(_ssv)]) : null;
|
||||
if (_dsrv) {
|
||||
host = _dsrv.host;
|
||||
selectedServer = _dsrv;
|
||||
selectedServerKey = _ssv || '';
|
||||
} else if (ssEl && ssEl.value === 'local') {
|
||||
host = '';
|
||||
} else {
|
||||
host = _envState.remoteHost || '';
|
||||
selectedServer = host ? ((_envState.servers || []).find(s => s.host === host) || _serverByVal?.(host) || null) : null;
|
||||
}
|
||||
}
|
||||
const srv = _serverByVal?.(_envState.remoteServerKey || host) || {};
|
||||
const env = host ? (srv.env || 'none') : (_envState.env || 'none');
|
||||
const srv = selectedServer || _serverByVal?.(host) || {};
|
||||
let env = host ? (srv.env || 'none') : (_envState.env || 'none');
|
||||
const envPath = host ? (srv.envPath || '') : (_envState.envPath || '');
|
||||
if ((!env || env === 'none') && envPath) {
|
||||
env = /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(envPath) ? 'venv' : env;
|
||||
}
|
||||
const platform = host ? (srv.platform || '') : (_envState.platform || '');
|
||||
const isWin = host ? (platform === 'windows') : _isWindows();
|
||||
|
||||
@@ -515,7 +525,13 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
// resumes cached partials more reliably.
|
||||
if ((model.required_gb || 0) >= 10 || backend === 'llamacpp') payload.disable_hf_transfer = true;
|
||||
if (_envState.hfToken) payload.hf_token = _envState.hfToken;
|
||||
if (host) { payload.remote_host = host; const _sp = _getPort(host); if (_sp) payload.ssh_port = _sp; }
|
||||
if (host) {
|
||||
payload.remote_host = host;
|
||||
if (selectedServerKey && selectedServerKey !== 'local') payload.remote_server_key = selectedServerKey;
|
||||
if (srv.name) payload.remote_server_name = srv.name;
|
||||
const _sp = srv.port || _getPort(host);
|
||||
if (_sp) payload.ssh_port = _sp;
|
||||
}
|
||||
if (platform) payload.platform = platform;
|
||||
// If this server has a directory flagged as the download target, send it so
|
||||
// the backend downloads into <dir>/<model> instead of the default HF cache.
|
||||
@@ -562,11 +578,12 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
if (zombieCandidate) {
|
||||
try {
|
||||
const _zh = zombieCandidate.remoteHost || '';
|
||||
const _zPort = (_serverByVal?.(_envState.remoteServerKey || _zh)
|
||||
const _zPort = (_serverByVal?.(zombieCandidate.remoteServerKey || zombieCandidate.payload?.remote_server_key || _zh)
|
||||
|| (_envState.servers || []).find(s => s.host === _zh) || {}).port;
|
||||
const _sshPf = _zh ? `ssh ${_zPort && _zPort !== '22' ? `-p ${_zPort} ` : ''}${_zh} '` : '';
|
||||
const _sshSf = _zh ? `'` : '';
|
||||
const _probeCmd = `${_sshPf}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
|
||||
const _probePrefix = _zh ? 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; ' : '';
|
||||
const _probeCmd = `${_sshPf}${_probePrefix}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
|
||||
const _r = await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -593,7 +610,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
if (activeOnHost) {
|
||||
const queueId = `queue-${Date.now().toString(36)}`;
|
||||
const allTasks = _loadTasks();
|
||||
allTasks.push({ id: queueId, sessionId: queueId, name: taskName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host });
|
||||
allTasks.push({ id: queueId, sessionId: queueId, name: taskName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host, remoteServerKey: payload.remote_server_key || '', remoteServerName: payload.remote_server_name || '', sshPort: payload.ssh_port || '', platform: payload.platform || '' });
|
||||
_saveTasks(allTasks);
|
||||
_renderRunningTab();
|
||||
uiModule.showToast(`Queued ${shortName} — waiting for current download`);
|
||||
|
||||
+306
-25
@@ -57,9 +57,24 @@ function _downloadDisplayName(name, task) {
|
||||
return part ? `${name} · ${part}` : name;
|
||||
}
|
||||
|
||||
function _downloadNameFromPayload(name, payload) {
|
||||
const rawName = String(name || '').trim();
|
||||
// Defensive: failed/restarted downloads can inherit the wrapper executable
|
||||
// name if older state was saved from a command preview. The row title should
|
||||
// always be the model/repo, never "bash" or "python".
|
||||
const looksLikeLauncher = /^(?:bash|sh|zsh|python|python3|pwsh|powershell|cmd|tmux)$/i.test(rawName);
|
||||
const base = (!rawName || looksLikeLauncher)
|
||||
? String(payload?.repo_id || payload?.repo || '').split('/').pop()
|
||||
: rawName;
|
||||
const include = payload?.include || '';
|
||||
if (!include || String(base || '').includes(' · ')) return base || rawName || 'download';
|
||||
const part = _ggufDisplayPartFromPath(String(include).replace(/\*/g, ''));
|
||||
return part ? `${base} · ${part}` : (base || rawName || 'download');
|
||||
}
|
||||
|
||||
function _taskDisplayName(task) {
|
||||
const name = String(task?.name || '').trim();
|
||||
if (task?.type === 'download') return _downloadDisplayName(name, task);
|
||||
if (task?.type === 'download') return _downloadDisplayName(_downloadNameFromPayload(name, task?.payload), task);
|
||||
if (task?.type !== 'serve') return name;
|
||||
const gguf = task?.payload?._fields?.gguf_file || task?.payload?.gguf_file || '';
|
||||
if (!gguf || name.includes(' · ')) return name;
|
||||
@@ -98,7 +113,7 @@ function _downloadOutputLooksActive(task) {
|
||||
|
||||
function _canClearTask(task) {
|
||||
if (!task || task.status === 'running') return false;
|
||||
if (task.type === 'serve' && (task.status === 'ready' || (task._serveReady && !['stopped', 'error', 'crashed', 'failed', 'completed'].includes(task.status)))) return false;
|
||||
if (task.type === 'serve' && (task.status === 'ready' || (!['error', 'crashed', 'failed', 'completed'].includes(task.status) && _serveOutputLooksReady(task)))) return false;
|
||||
// If the tmux output still shows an in-flight download, the task isn't
|
||||
// actually finished — hide the clear/check pill so it doesn't show on a
|
||||
// task that's still doing work. (The next render will reflect this and
|
||||
@@ -334,6 +349,34 @@ function _taskServerSelection(task) {
|
||||
return { host, server, key };
|
||||
}
|
||||
|
||||
function _serverColorForTaskGroup(key, tasks) {
|
||||
const firstTask = Array.isArray(tasks) ? tasks[0] : null;
|
||||
const host = firstTask?.remoteHost || firstTask?.payload?.remote_host || '';
|
||||
const savedKey = firstTask?.remoteServerKey || firstTask?.payload?.remote_server_key || key || '';
|
||||
const server = (savedKey ? _serverByVal?.(savedKey) : null)
|
||||
|| (key ? _serverByVal?.(key) : null)
|
||||
|| (host ? _serverByVal?.(host) : null)
|
||||
|| (key === 'local' || !key ? (_envState?.servers || []).find(s => !s.host || String(s.host).toLowerCase() === 'local') : null)
|
||||
|| null;
|
||||
const color = String(server?.color || '').trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '';
|
||||
}
|
||||
|
||||
function _serverHeaderStyle(color) {
|
||||
if (!color) return '';
|
||||
const c = color.toLowerCase();
|
||||
const accent = (c === '#ffffff' || c === '#f8fafc') ? '#cbd5e1'
|
||||
: (c === '#111827' || c === '#000000') ? '#64748b'
|
||||
: color;
|
||||
return ` style="--cookbook-server-color:${esc(color)};--cookbook-server-accent:${esc(accent)};"`;
|
||||
}
|
||||
|
||||
function _shouldAutoExpandTaskOutput(task) {
|
||||
return task?.type === 'download'
|
||||
&& !task?.payload?._dep
|
||||
&& ['running', 'queued', 'error', 'crashed'].includes(String(task?.status || ''));
|
||||
}
|
||||
|
||||
function _selectTaskServer(task) {
|
||||
const { host, server, key } = _taskServerSelection(task);
|
||||
_envState.remoteHost = host;
|
||||
@@ -366,6 +409,7 @@ let _soloExpandTaskId = null;
|
||||
const TASKS_KEY = 'cookbook-tasks';
|
||||
const STORAGE_KEY = 'cookbook-presets';
|
||||
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
|
||||
|
||||
// Polling / timeout intervals
|
||||
const TASK_POLL_INTERVAL_MS = 3000; // delay between reconnect-loop iterations
|
||||
@@ -489,7 +533,7 @@ function _refreshModelsAfterEndpointChange() {
|
||||
pickerLabel.innerHTML = '<span style="opacity:0.4;">refreshing…</span>';
|
||||
}
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels(true);
|
||||
window.modelsModule.refreshModels(false);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!window.sessionModule) return;
|
||||
@@ -545,6 +589,53 @@ function _endpointFromAdvertisedUrl(rawUrl, currentHost, fallbackPort = '11434')
|
||||
}
|
||||
}
|
||||
|
||||
function _serveExpectedModel(task) {
|
||||
const fields = task?.payload?._fields || {};
|
||||
return String(
|
||||
fields.served_model_name ||
|
||||
fields.model_path ||
|
||||
task?.payload?.repo_id ||
|
||||
task?.model ||
|
||||
task?.name ||
|
||||
''
|
||||
).trim();
|
||||
}
|
||||
|
||||
function _modelIdMatchesExpected(modelId, expected) {
|
||||
const got = String(modelId || '').trim().toLowerCase();
|
||||
const want = String(expected || '').trim().toLowerCase();
|
||||
if (!got || !want) return true;
|
||||
if (got === want) return true;
|
||||
const gotBase = got.split('/').pop();
|
||||
const wantBase = want.split('/').pop();
|
||||
return gotBase === wantBase || got.includes(wantBase) || want.includes(gotBase);
|
||||
}
|
||||
|
||||
function _endpointMatchesServe(ep, task) {
|
||||
const expected = _serveExpectedModel(task);
|
||||
const models = [...(ep?.models || []), ...(ep?.pinned_models || [])];
|
||||
if (!models.length) return true;
|
||||
return models.some(mid => _modelIdMatchesExpected(mid, expected));
|
||||
}
|
||||
|
||||
function _markServeEndpointMismatch(task, ep, host, port) {
|
||||
const expected = _serveExpectedModel(task);
|
||||
const actual = (ep?.models || []).join(', ') || 'no models';
|
||||
const msg = `Port ${host}:${port} answered, but it is serving ${actual}, not ${expected || task?.name || 'the launched model'}. The new serve likely failed or the port is occupied by an older server.`;
|
||||
_updateTask(task.sessionId || task.session_id, {
|
||||
status: 'error',
|
||||
_serveReady: false,
|
||||
_endpointAdded: false,
|
||||
output: `${task.output || ''}\n\n${msg}`.trim(),
|
||||
});
|
||||
uiModule.showError(msg);
|
||||
}
|
||||
|
||||
function _appendPinnedServeModel(fd, task) {
|
||||
const expected = _serveExpectedModel(task);
|
||||
if (expected) fd.append('pinned_models', expected);
|
||||
}
|
||||
|
||||
// ── Download queue — runs one at a time per server ──
|
||||
|
||||
function _processQueue() {
|
||||
@@ -891,13 +982,17 @@ function _taskRemoteHost(task) {
|
||||
return task?.remoteHost || task?.payload?.remote_host || '';
|
||||
}
|
||||
|
||||
function _remoteTmuxPrefix() {
|
||||
return 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; ';
|
||||
}
|
||||
|
||||
export function _tmuxCmd(task, tmuxArgs) {
|
||||
if (_isWindows(task)) {
|
||||
return _winSessionCmd(task, tmuxArgs);
|
||||
}
|
||||
const host = _taskRemoteHost(task);
|
||||
if (host) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${host} 'tmux ${tmuxArgs}' 2>/dev/null`;
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${host} '${_remoteTmuxPrefix()}tmux ${tmuxArgs}' 2>/dev/null`;
|
||||
}
|
||||
return `tmux ${tmuxArgs} 2>/dev/null`;
|
||||
}
|
||||
@@ -930,7 +1025,7 @@ function _winSessionCmd(task, tmuxArgs) {
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`;
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
|
||||
return host ? `ssh ${pf}${host} '${_remoteTmuxPrefix()}tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
|
||||
}
|
||||
|
||||
function _winPowerShellCmd(task, ps) {
|
||||
@@ -957,7 +1052,7 @@ export function _tmuxGracefulKill(task) {
|
||||
}
|
||||
const host = _taskRemoteHost(task);
|
||||
if (host) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${host} 'tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`;
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${host} '${_remoteTmuxPrefix()}tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`;
|
||||
}
|
||||
return `tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null`;
|
||||
}
|
||||
@@ -984,7 +1079,7 @@ export function _tmuxForceKill(task) {
|
||||
`tmux kill-session -t ${sid} 2>/dev/null`;
|
||||
const host = _taskRemoteHost(task);
|
||||
if (host) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(inner)}`;
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(_remoteTmuxPrefix() + inner)}`;
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
@@ -1001,7 +1096,7 @@ export function _tmuxIsAliveCheck(task) {
|
||||
const inner = `if tmux has-session -t ${sid} 2>/dev/null; then echo ALIVE; else echo DEAD; fi`;
|
||||
const host = _taskRemoteHost(task);
|
||||
if (host) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(inner)}`;
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(_remoteTmuxPrefix() + inner)}`;
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
@@ -1225,8 +1320,13 @@ function _syncToServer() {
|
||||
presets: _loadPresets(),
|
||||
env: _envState,
|
||||
serveState: null,
|
||||
serveFavorites: [],
|
||||
};
|
||||
try { state.serveState = JSON.parse(localStorage.getItem(SERVE_STATE_KEY)); } catch {}
|
||||
try {
|
||||
const favorites = JSON.parse(localStorage.getItem(SERVE_FAVORITES_KEY) || '[]');
|
||||
state.serveFavorites = Array.isArray(favorites) ? favorites.filter(Boolean).map(String) : [];
|
||||
} catch {}
|
||||
await fetch('/api/cookbook/state', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1236,6 +1336,10 @@ function _syncToServer() {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
document.addEventListener('cookbook:state-dirty', () => {
|
||||
_syncToServer();
|
||||
});
|
||||
|
||||
// Normalize state from server: collapse legacy duplicate keys to canonical form.
|
||||
// - server.modelDir (singular) → server.modelDirs[0] (canonical)
|
||||
// - strip ✕/✖ pollution from modelDirs
|
||||
@@ -1317,6 +1421,9 @@ export async function _syncFromServer() {
|
||||
if (state.serveState) {
|
||||
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify(state.serveState));
|
||||
}
|
||||
if (Array.isArray(state.serveFavorites)) {
|
||||
localStorage.setItem(SERVE_FAVORITES_KEY, JSON.stringify(state.serveFavorites.filter(Boolean).map(String)));
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('cookbook:state-synced', { detail: state }));
|
||||
return true;
|
||||
} catch { return false; }
|
||||
@@ -1384,6 +1491,7 @@ async function _retryDownload(name, payload, replaceSessionId = '') {
|
||||
const tasks = _loadTasks();
|
||||
const task = tasks.find(t => t.sessionId === replaceSessionId);
|
||||
if (task) {
|
||||
task.name = _downloadNameFromPayload(name || task.name, _payload);
|
||||
task.id = data.session_id;
|
||||
task.sessionId = data.session_id;
|
||||
task.status = 'running';
|
||||
@@ -1510,6 +1618,11 @@ export async function _serveAutoRetryReplace(panel, flag, value) {
|
||||
_animateOutThenRemove(taskEl, taskId);
|
||||
|
||||
let newCmd = task.payload._cmd;
|
||||
if (flag === '--cuda-graph-backend-decode') {
|
||||
newCmd = newCmd.replace(/\s+--cuda-graph-max-bs-decode(?:\s+\S+|=\S+)/g, '');
|
||||
} else if (flag === '--cuda-graph-max-bs-decode') {
|
||||
newCmd = newCmd.replace(/\s+--cuda-graph-backend-decode(?:\s+\S+|=\S+)/g, '');
|
||||
}
|
||||
const re = new RegExp(flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s+\\S+');
|
||||
if (re.test(newCmd)) {
|
||||
newCmd = newCmd.replace(re, `${flag} ${value}`);
|
||||
@@ -1641,6 +1754,7 @@ function _parseServeCmdToFields(cmd) {
|
||||
const ex = (re) => { const m = cmd.match(re); return m ? m[1] : ''; };
|
||||
const fields = {
|
||||
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp'
|
||||
: cmd.includes('mlx_lm.server') ? 'mlx'
|
||||
: cmd.includes('diffusion_server') ? 'diffusers'
|
||||
: cmd.includes('sglang') ? 'sglang'
|
||||
: cmd.includes('ollama') ? 'ollama' : 'vllm',
|
||||
@@ -1678,6 +1792,100 @@ function _parseServeCmdToFields(cmd) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
function _serveCmdNeedsGpuPreflight(cmd, repo) {
|
||||
const c = String(cmd || '').toLowerCase();
|
||||
const r = String(repo || '').toLowerCase();
|
||||
if (!c || /gpu-cleanup|sglang-kernel|mlx-lm|pip\s+install|python\d*\s+-m\s+pip/.test(`${r} ${c}`)) return false;
|
||||
return /\b(vllm\s+serve|sglang(?:\.launch_server|\s+serve)|mlx_lm\.server|llama-server|llama_cpp\.server|text-generation-launcher|aphrodite|ollama\s+(?:serve|run))\b/.test(c);
|
||||
}
|
||||
|
||||
function _selectedGpuIndexes(gpus) {
|
||||
const raw = String(gpus || '').trim();
|
||||
if (!raw) return null;
|
||||
const out = new Set();
|
||||
raw.split(',').forEach(part => {
|
||||
const p = part.trim();
|
||||
const range = p.match(/^(\d+)\s*-\s*(\d+)$/);
|
||||
if (range) {
|
||||
const a = parseInt(range[1], 10);
|
||||
const b = parseInt(range[2], 10);
|
||||
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i);
|
||||
return;
|
||||
}
|
||||
const n = parseInt(p, 10);
|
||||
if (Number.isFinite(n)) out.add(n);
|
||||
});
|
||||
return out.size ? out : null;
|
||||
}
|
||||
|
||||
function _gbFromMb(mb) {
|
||||
const n = Number(mb || 0);
|
||||
if (!Number.isFinite(n) || n <= 0) return '';
|
||||
return n >= 1024 ? `${(n / 1024).toFixed(n >= 10240 ? 0 : 1)}G` : `${Math.round(n)}M`;
|
||||
}
|
||||
|
||||
function _gpuPreflightIssues(data, selected) {
|
||||
const backend = String(data?.backend || data?.source || '').toLowerCase();
|
||||
const isCuda = backend.includes('cuda') || String(data?.source || '').toLowerCase().includes('nvidia');
|
||||
const rows = Array.isArray(data?.gpus) ? data.gpus : [];
|
||||
const issues = [];
|
||||
rows.forEach(g => {
|
||||
const idx = Number(g?.index);
|
||||
if (selected && !selected.has(idx)) return;
|
||||
const procs = Array.isArray(g?.processes) ? g.processes : [];
|
||||
if (procs.length) {
|
||||
procs.slice(0, 3).forEach(p => {
|
||||
const name = String(p?.name || 'process').split(/[\\/]/).pop();
|
||||
const used = _gbFromMb(p?.used_mb);
|
||||
issues.push(`GPU ${idx}: ${name}${p?.pid ? ` #${p.pid}` : ''}${used ? ` (${used})` : ''}`);
|
||||
});
|
||||
if (procs.length > 3) issues.push(`GPU ${idx}: +${procs.length - 3} more process${procs.length - 3 === 1 ? '' : 'es'}`);
|
||||
return;
|
||||
}
|
||||
const total = Number(g?.total_mb || 0);
|
||||
const free = Number(g?.free_mb || 0);
|
||||
const used = Number(g?.used_mb || 0);
|
||||
const freeRatio = total > 0 ? free / total : 1;
|
||||
// CUDA can have display/runtime crumbs; warn only for meaningful occupied memory.
|
||||
if (isCuda && used > 4096 && freeRatio < 0.9) {
|
||||
issues.push(`GPU ${idx}: ${_gbFromMb(used)} already used (${_gbFromMb(free)} free)`);
|
||||
} else if (!isCuda && total > 0 && freeRatio < 0.2) {
|
||||
issues.push(`${g?.name || `GPU ${idx}`}: low free memory (${_gbFromMb(free)} free of ${_gbFromMb(total)})`);
|
||||
} else if (!isCuda && g?.busy && total <= 0) {
|
||||
issues.push(`${g?.name || `GPU ${idx}`}: GPU device is busy`);
|
||||
}
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function _confirmGpuPreflight(reqBody, shortName, repo, cmd) {
|
||||
if (!_serveCmdNeedsGpuPreflight(cmd, repo)) return true;
|
||||
const params = new URLSearchParams();
|
||||
if (reqBody.remote_host) params.set('host', reqBody.remote_host);
|
||||
if (reqBody.ssh_port) params.set('ssh_port', reqBody.ssh_port);
|
||||
try {
|
||||
const res = await fetch(`/api/cookbook/gpus${params.toString() ? `?${params.toString()}` : ''}`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || !data?.ok) return true;
|
||||
const selected = _selectedGpuIndexes(reqBody.gpus);
|
||||
const issues = _gpuPreflightIssues(data, selected);
|
||||
if (!issues.length) return true;
|
||||
const where = reqBody.remote_host || 'local';
|
||||
const list = issues.slice(0, 6).join('; ');
|
||||
const more = issues.length > 6 ? `; +${issues.length - 6} more` : '';
|
||||
const msg = `GPU preflight found existing load on ${where}: ${list}${more}. Launch ${shortName || 'model'} anyway?`;
|
||||
const confirm = window.styledConfirm || uiModule?.styledConfirm;
|
||||
if (confirm) return await confirm(msg, { confirmText: 'Launch anyway', cancelText: 'Cancel' });
|
||||
return window.confirm ? window.confirm(msg) : true;
|
||||
} catch (e) {
|
||||
console.warn('[cookbook] GPU preflight failed; allowing launch', e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function _launchServeTask(shortName, repo, cmd, fields, hostOverride, targetMeta = null) {
|
||||
// Host resolution mirrors the download path: when the caller passes an explicit
|
||||
// host (resolved from the dropdown the user actually picked), use it and look
|
||||
@@ -1761,11 +1969,16 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
ssh_port: _getPort(_serverMetaKey || _host) || undefined,
|
||||
env_prefix: envPrefix || undefined,
|
||||
hf_token: _envState.hfToken || undefined,
|
||||
gpus: _envState.gpus || undefined,
|
||||
gpus: _usedGpus || undefined,
|
||||
platform: _hplatform || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const _preflightOk = await _confirmGpuPreflight(reqBody, shortName, repo, cmd);
|
||||
if (!_preflightOk) {
|
||||
uiModule.showToast('Launch cancelled — GPU is already in use');
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/api/model/serve', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1989,7 +2202,8 @@ export function _renderRunningTab() {
|
||||
// green when reachable, red if any serve task on it is crashed/unreachable.
|
||||
const _secDot = (key && allTasks.some(_serveTaskFailed)) ? 'fail' : 'ok';
|
||||
const _dotTitle = key ? (_secDot === 'fail' ? 'Server not responding' : 'Reachable') : 'Local (this machine)';
|
||||
sec.insertAdjacentHTML('afterbegin', `<div class="cookbook-section-header" data-collapse="${bodyId}"><svg class="cookbook-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg><span class="cookbook-srv-status ${_secDot}" title="${_dotTitle}" style="flex-shrink:0;position:relative;top:0px;"></span><span class="cookbook-section-title" style="margin:0;">${esc(sg.name)}</span><button class="cookbook-btn cookbook-stop-all-btn" data-stop-server="${esc(key)}" title="Stop all running servers"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg>Stop all</button><button class="cookbook-btn cookbook-clear-btn" data-clear-server="${esc(key)}" title="Clear finished tasks"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Clear finished</button></div><div id="${bodyId}" class="cookbook-section-body"></div>`);
|
||||
const _srvColor = _serverColorForTaskGroup(key || 'local', allTasks);
|
||||
sec.insertAdjacentHTML('afterbegin', `<div class="cookbook-section-header${_srvColor ? ' has-server-color' : ''}" data-collapse="${bodyId}"${_serverHeaderStyle(_srvColor)}><svg class="cookbook-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg><span class="cookbook-srv-status ${_secDot}" title="${_dotTitle}" style="flex-shrink:0;position:relative;top:0px;"></span><span class="cookbook-section-title" style="margin:0;">${esc(sg.name)}</span><button class="cookbook-btn cookbook-stop-all-btn" data-stop-server="${esc(key)}" title="Stop all running servers"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg>Stop all</button><button class="cookbook-btn cookbook-clear-btn" data-clear-server="${esc(key)}" title="Clear finished tasks"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Clear finished</button></div><div id="${bodyId}" class="cookbook-section-body"></div>`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2170,7 +2384,7 @@ export function _renderRunningTab() {
|
||||
<button class="cookbook-task-menu-btn" title="Actions">⋮</button>
|
||||
</div>
|
||||
<div class="cookbook-task-sub"><span class="cookbook-task-session">${esc(task.sessionId)}</span><span class="cookbook-task-uptime" style="display:${((task.type === 'serve' || task.type === 'download') && task.status === 'running') ? '' : 'none'}"></span>${(task.type === 'download') ? `<span class="cookbook-task-dldir" title="Download destination" style="font-size:9px;color:var(--fg-muted);font-family:'Fira Code',monospace;opacity:0.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40ch;">Dir: ${esc(task.payload?.local_dir || '~/.cache/huggingface/hub')}</span>` : ''}</div>
|
||||
<div class="cookbook-output-wrap cookbook-task-collapsible${_mobileCollapseDefault ? ' cookbook-task-collapsed' : ''}"><pre class="cookbook-output-pre">${esc(task.output || '')}</pre><button type="button" class="copy-code cookbook-output-copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>
|
||||
<div class="cookbook-output-wrap cookbook-task-collapsible${(_mobileCollapseDefault && !_shouldAutoExpandTaskOutput(task)) ? ' cookbook-task-collapsed' : ''}"><pre class="cookbook-output-pre">${esc(task.output || '')}</pre><button type="button" class="copy-code cookbook-output-copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>
|
||||
`;
|
||||
|
||||
const _waveEl = el.querySelector('.cookbook-task-wave');
|
||||
@@ -2305,7 +2519,23 @@ export function _renderRunningTab() {
|
||||
el.querySelector('.cookbook-task-header').addEventListener('click', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
const wrap = el.querySelector('.cookbook-output-wrap');
|
||||
if (wrap) wrap.classList.toggle('cookbook-task-collapsed');
|
||||
if (!wrap) return;
|
||||
const isOpening = wrap.classList.contains('cookbook-task-collapsed');
|
||||
wrap.classList.toggle('cookbook-task-collapsed');
|
||||
if (isOpening) {
|
||||
_expandedTaskIds.add(task.sessionId);
|
||||
_collapsedTaskIds.delete(task.sessionId);
|
||||
if (task.sessionId && ['serve', 'download'].includes(task.type || '')) {
|
||||
_reconnectTask(el, task);
|
||||
}
|
||||
} else {
|
||||
_collapsedTaskIds.add(task.sessionId);
|
||||
_expandedTaskIds.delete(task.sessionId);
|
||||
if (el._abort) {
|
||||
try { el._abort.abort(); } catch {}
|
||||
el._abort = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wire menu button (also fire from a long-press anywhere on the card so
|
||||
@@ -2344,10 +2574,18 @@ export function _renderRunningTab() {
|
||||
el.addEventListener('touchcancel', _lpCancel, { passive: true });
|
||||
menuBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const existing = document.querySelector('.cookbook-task-dropdown');
|
||||
if (existing && existing._anchor === menuBtn) {
|
||||
if (typeof existing._dismiss === 'function') existing._dismiss();
|
||||
else existing.remove();
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.cookbook-task-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); });
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'cookbook-task-dropdown';
|
||||
dropdown._anchor = menuBtn;
|
||||
menuBtn.classList.add('cookbook-menu-active');
|
||||
|
||||
const items = [];
|
||||
// ── Run section ─────────────────────────────────────────────
|
||||
@@ -2549,7 +2787,7 @@ export function _renderRunningTab() {
|
||||
}
|
||||
|
||||
const closeHandler = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== menuBtn) {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== menuBtn && !menuBtn.contains(ev.target)) {
|
||||
_cleanup();
|
||||
}
|
||||
};
|
||||
@@ -2561,6 +2799,7 @@ export function _renderRunningTab() {
|
||||
const _cleanup = () => {
|
||||
_unreg(); _unreg = () => {};
|
||||
dropdown.remove();
|
||||
menuBtn.classList.remove('cookbook-menu-active');
|
||||
document.removeEventListener('click', closeHandler);
|
||||
window.removeEventListener('scroll', scrollClose, true);
|
||||
window.visualViewport?.removeEventListener('scroll', scrollClose);
|
||||
@@ -2732,7 +2971,9 @@ export function _renderRunningTab() {
|
||||
// responds; without this, the user opens the Running tab and sees
|
||||
// only the placeholder ("Launched by scheduled task …") because
|
||||
// _reconnectTask never fires for status 'ready'/'loading'/'warming'.
|
||||
if (_isRunningTabVisible() && ['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status)) {
|
||||
const _wrapForStream = el.querySelector('.cookbook-output-wrap');
|
||||
const _streamExpanded = _wrapForStream && !_wrapForStream.classList.contains('cookbook-task-collapsed');
|
||||
if (_isRunningTabVisible() && _streamExpanded && task.sessionId && ['serve', 'download'].includes(task.type || '')) {
|
||||
_reconnectTask(el, task);
|
||||
}
|
||||
}
|
||||
@@ -2764,13 +3005,19 @@ export function _renderRunningTab() {
|
||||
// ── Reconnect task (polling loop) ──
|
||||
|
||||
async function _reconnectTask(el, task) {
|
||||
if (!el || !task) return;
|
||||
const wrap = el.querySelector('.cookbook-output-wrap');
|
||||
if (!_isRunningTabVisible() || !wrap || wrap.classList.contains('cookbook-task-collapsed')) return;
|
||||
if (el._abort && !el._abort.signal?.aborted) return;
|
||||
const output = el.querySelector('.cookbook-output-pre');
|
||||
if (!output) return;
|
||||
const controller = new AbortController();
|
||||
el._abort = controller;
|
||||
let failCount = 0;
|
||||
|
||||
while (!controller.signal.aborted) {
|
||||
if (!el.isConnected) {
|
||||
const liveWrap = el.querySelector('.cookbook-output-wrap');
|
||||
if (!el.isConnected || !_isRunningTabVisible() || !liveWrap || liveWrap.classList.contains('cookbook-task-collapsed')) {
|
||||
controller.abort();
|
||||
break;
|
||||
}
|
||||
@@ -3358,13 +3605,17 @@ async function _reconnectTask(el, task) {
|
||||
// endpoints server-side. Mark so we don't retry, but STILL
|
||||
// refresh the picker (and probe until online) so the new model
|
||||
// shows up without the user having to manually refresh.
|
||||
const _ex = eps.find(e => e.base_url === baseUrl);
|
||||
if (_ex && !_endpointMatchesServe(_ex, task)) {
|
||||
_markServeEndpointMismatch(task, _ex, host, port);
|
||||
return null;
|
||||
}
|
||||
task._endpointAdded = true;
|
||||
_updateTask(task.sessionId, { _endpointAdded: true });
|
||||
_autoSaveWorkingConfig(task); // endpoint live → remember these settings
|
||||
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
|
||||
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
|
||||
if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
|
||||
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl, host, port, model: task.name } }));
|
||||
const _ex = eps.find(e => e.base_url === baseUrl);
|
||||
if (_ex && _ex.id && !(_ex.models || []).length) _probeEndpointUntilOnline(_ex.id, host, port);
|
||||
return null;
|
||||
}
|
||||
@@ -3374,6 +3625,7 @@ async function _reconnectTask(el, task) {
|
||||
fd.append('name', task.name);
|
||||
fd.append('skip_probe', 'true');
|
||||
_appendCookbookEndpointScope(fd, task.remoteHost || '');
|
||||
_appendPinnedServeModel(fd, task);
|
||||
if (_isDiffusion) fd.append('model_type', 'image');
|
||||
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
})
|
||||
@@ -3392,7 +3644,7 @@ async function _reconnectTask(el, task) {
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl, host, port, model: task.name } }));
|
||||
const _trySelectModel = async (attempt) => {
|
||||
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
|
||||
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
|
||||
const items = window.modelsModule?.getCachedItems?.() || [];
|
||||
for (const item of items) {
|
||||
if (item.offline) continue;
|
||||
@@ -3479,6 +3731,16 @@ function _isRunningTabVisible() {
|
||||
return activeTab === 'Running';
|
||||
}
|
||||
|
||||
function _isCookbookVisible() {
|
||||
try {
|
||||
if (window.cookbookModule && typeof window.cookbookModule.isVisible === 'function') {
|
||||
return !!window.cookbookModule.isVisible();
|
||||
}
|
||||
} catch (_) {}
|
||||
const modal = document.getElementById('cookbook-modal');
|
||||
return !!modal && !modal.classList.contains('hidden');
|
||||
}
|
||||
|
||||
function _foregroundChatBusy() {
|
||||
try {
|
||||
return !!window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0);
|
||||
@@ -3780,6 +4042,7 @@ function _stopBackgroundMonitor() {
|
||||
// the endpoint reports models, then refreshes the picker. Bounded so a
|
||||
// genuinely-dead server doesn't poll forever.
|
||||
async function _probeEndpointUntilOnline(epId, host, port) {
|
||||
if (!_isCookbookVisible() || _foregroundChatBusy()) return;
|
||||
if (!epId) return;
|
||||
// Big models (e.g. 70B+) can take several minutes to load weights before
|
||||
// the server answers /v1/models. Probe for up to ~5 min, easing the
|
||||
@@ -3788,6 +4051,7 @@ async function _probeEndpointUntilOnline(epId, host, port) {
|
||||
for (let i = 0; i < MAX_TRIES; i++) {
|
||||
const interval = i < 12 ? 5000 : 10000; // 5s for the first minute, then 10s
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
if (!_isCookbookVisible() || _foregroundChatBusy()) return;
|
||||
try {
|
||||
// Hit the probe endpoint — it re-probes server-side and updates
|
||||
// cached_models. We consume (and discard) the SSE stream.
|
||||
@@ -3797,7 +4061,7 @@ async function _probeEndpointUntilOnline(epId, host, port) {
|
||||
const eps = await fetch('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []);
|
||||
const ep = (eps || []).find(e => e.id === epId);
|
||||
if (ep && (ep.models || []).length) {
|
||||
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
|
||||
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
|
||||
if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
|
||||
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', {
|
||||
detail: { baseUrl: ep.base_url || `http://${host}:${port}/v1`, host, port, model: (ep.models || [])[0] || '' },
|
||||
@@ -3891,7 +4155,8 @@ async function _pollBackgroundStatus() {
|
||||
// "stopped" by the backend (its pip package is never in the HF cache the
|
||||
// dead-session check inspects). Recover "done" from the retained output's
|
||||
// exit-0 sentinel so a clean install isn't downgraded to crashed.
|
||||
const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);
|
||||
const combinedOutput = `${task.output || ''}\n${live.output_tail || ''}`;
|
||||
const depDone = !!task.payload?._dep && _depInstallSucceeded(combinedOutput);
|
||||
// A finished model download whose tmux pane is gone is also reported
|
||||
// "stopped" (the dead-session check can miss the landed snapshot).
|
||||
// Recover "done" from the terminal `DOWNLOAD_OK` sentinel — emitted
|
||||
@@ -3901,19 +4166,29 @@ async function _pollBackgroundStatus() {
|
||||
// off the conclusive exit sentinel only, never the `/snapshots/` path,
|
||||
// which can be printed mid-stream for multi-file downloads.
|
||||
const downloadDone = task.type === 'download'
|
||||
&& String(task.output || '').includes('DOWNLOAD_OK');
|
||||
const nextStatus = live.status === 'completed'
|
||||
&& String(combinedOutput || '').includes('DOWNLOAD_OK');
|
||||
const serveReady = task.type === 'serve'
|
||||
&& (live.status === 'ready' || _serveOutputLooksReady({ ...task, output: live.output_tail || task.output || '' }));
|
||||
const completedByOutput = depDone || downloadDone;
|
||||
const nextStatus = completedByOutput
|
||||
? 'done'
|
||||
: (serveReady
|
||||
? 'ready'
|
||||
: (live.status === 'completed'
|
||||
? 'done'
|
||||
: (live.status === 'error'
|
||||
? 'error'
|
||||
: (live.status === 'stopped'
|
||||
? ((depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped'))
|
||||
: null));
|
||||
: null))));
|
||||
if (nextStatus && task.status !== nextStatus) {
|
||||
updates.status = nextStatus;
|
||||
if (nextStatus === 'done' && task.payload?._dep) completedDeps.push(task);
|
||||
}
|
||||
if ((live.status === 'running' || live.status === 'ready') && task.status !== live.status) {
|
||||
if (serveReady && !task._serveReady) {
|
||||
updates._serveReady = true;
|
||||
}
|
||||
if ((live.status === 'running' || live.status === 'ready') && task.status !== live.status && !serveReady && !completedByOutput) {
|
||||
updates.status = live.status === 'ready' ? 'ready' : 'running';
|
||||
}
|
||||
if (live.progress && live.progress !== task.progress) updates.progress = live.progress;
|
||||
@@ -3993,6 +4268,11 @@ async function _pollBackgroundStatus() {
|
||||
const hostPort = `${host}:${port}`;
|
||||
const existing = eps.find(e => e.base_url === baseUrl || e.base_url.includes(hostPort) || e.name === t.model);
|
||||
if (existing) {
|
||||
const taskForMatch = localTask || { sessionId: t.session_id, name: t.model, model: t.model, payload: { repo_id: t.model, _cmd } };
|
||||
if (!_endpointMatchesServe(existing, taskForMatch)) {
|
||||
_markServeEndpointMismatch(taskForMatch, existing, host, port);
|
||||
return null;
|
||||
}
|
||||
// Already registered — but it may be showing offline because
|
||||
// it was added while the server was still warming. Kick a
|
||||
// re-probe so it flips online without manual toggle.
|
||||
@@ -4004,6 +4284,7 @@ async function _pollBackgroundStatus() {
|
||||
fd.append('name', t.model);
|
||||
fd.append('skip_probe', 'true');
|
||||
_appendCookbookEndpointScope(fd, localTask?.remoteHost || t.remote || '');
|
||||
_appendPinnedServeModel(fd, localTask || { name: t.model, model: t.model, payload: { repo_id: t.model, _cmd } });
|
||||
if (_isDiffusion) fd.append('model_type', 'image');
|
||||
if (_supportsTools) fd.append('supports_tools', 'true');
|
||||
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
@@ -4016,7 +4297,7 @@ async function _pollBackgroundStatus() {
|
||||
// probe, so it lands "offline". Retry-probe in the background
|
||||
// until /v1/models responds — no manual enable/disable needed.
|
||||
if (data && data.id) _probeEndpointUntilOnline(data.id, host, port);
|
||||
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
|
||||
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
|
||||
if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
|
||||
}
|
||||
})
|
||||
|
||||
+112
-29
@@ -49,11 +49,25 @@ let _cachedAllModels = [];
|
||||
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v1';
|
||||
const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000;
|
||||
|
||||
function _normalizeCookbookModelDir(dir) {
|
||||
const d = String(dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
|
||||
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
|
||||
}
|
||||
|
||||
function _readCachedModelScan(sig) {
|
||||
try {
|
||||
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
|
||||
const entry = all[sig];
|
||||
if (entry && Date.now() - (entry.ts || 0) < _CACHED_MODELS_SCAN_TTL) return entry.data || null;
|
||||
if (entry && Date.now() - (entry.ts || 0) < _CACHED_MODELS_SCAN_TTL) {
|
||||
const data = entry.data || null;
|
||||
const models = Array.isArray(data?.models) ? data.models : [];
|
||||
const staleDownloading = models.some(m =>
|
||||
(m?.status === 'downloading' || m?.has_incomplete) && !_isActivelyDownloading(m?.repo_id)
|
||||
);
|
||||
if (!staleDownloading) return data;
|
||||
delete all[sig];
|
||||
localStorage.setItem(_CACHED_MODELS_SCAN_KEY, JSON.stringify(all));
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
@@ -83,6 +97,7 @@ function _loadServeFavorites() {
|
||||
function _saveServeFavorites(favorites) {
|
||||
try {
|
||||
localStorage.setItem(SERVE_FAVORITES_KEY, JSON.stringify(Array.from(favorites || [])));
|
||||
document.dispatchEvent(new CustomEvent('cookbook:state-dirty', { detail: { key: SERVE_FAVORITES_KEY } }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -218,7 +233,21 @@ function _shellSplitForPreview(cmd) {
|
||||
}
|
||||
|
||||
function _formatServeCmdPreview(cmd) {
|
||||
const raw = String(cmd || '');
|
||||
let raw = String(cmd || '');
|
||||
const mlxDeepSeekV4Compat = /\bmlx_lm\.server\b/i.test(raw)
|
||||
&& /--model\s+['"]?mlx-community\/[^'"\s]*deepseek-v4/i.test(raw);
|
||||
if (mlxDeepSeekV4Compat) {
|
||||
const modelMatch = raw.match(/--model\s+(['"]?)(mlx-community\/[^'"\s]*deepseek-v4[^'"\s]*)\1/i);
|
||||
const homeMatch = raw.match(/((?:\/Users|\/home)\/[^/\s'"]+)/);
|
||||
const shortName = modelMatch?.[2]?.split('/').pop();
|
||||
if (homeMatch && shortName) {
|
||||
const shimPath = `${homeMatch[1]}/.cache/odysseus/mlx-shims/${shortName}`;
|
||||
raw = raw.replace(
|
||||
/--model\s+(['"]?)mlx-community\/[^'"\s]*deepseek-v4[^'"\s]*\1/i,
|
||||
`--model '${shimPath}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (raw.startsWith('MODEL_FILE=$({')) {
|
||||
const marker = /&&\s+([A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)?(?:llama-server|python3?\s+-m\s+llama_cpp\.server)\b/;
|
||||
const match = raw.match(marker);
|
||||
@@ -233,7 +262,7 @@ function _formatServeCmdPreview(cmd) {
|
||||
const lines = [];
|
||||
let i = 0;
|
||||
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) {
|
||||
lines.push(tokens[i]);
|
||||
lines.push(`export ${tokens[i]}`);
|
||||
i++;
|
||||
}
|
||||
if (tokens[i]) {
|
||||
@@ -254,11 +283,43 @@ function _formatServeCmdPreview(cmd) {
|
||||
lines.push(t);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
const envCount = lines.findIndex(line => !line.startsWith('export '));
|
||||
const firstCmdLine = envCount < 0 ? lines.length : envCount;
|
||||
const formatted = lines.map((line, idx) => {
|
||||
const isCommandPart = idx >= firstCmdLine;
|
||||
const hasNextCommandPart = lines.slice(idx + 1).some(next => !next.startsWith('export '));
|
||||
return isCommandPart && hasNextCommandPart ? `${line} \\` : line;
|
||||
}).join('\n');
|
||||
if (mlxDeepSeekV4Compat) {
|
||||
return [
|
||||
'# Odysseus runtime compatibility: using sanitized MLX DeepSeek-V4 shim.',
|
||||
formatted,
|
||||
].join('\n');
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function _normalizeServeCmdForLaunch(cmd) {
|
||||
return String(cmd || '')
|
||||
let raw = String(cmd || '');
|
||||
const lines = raw.split(/\r?\n/)
|
||||
.map(s => s.trim().replace(/\s*\\$/, '').trim())
|
||||
.filter(s => s && !s.startsWith('#'));
|
||||
if (lines.some(line => /^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=/.test(line))) {
|
||||
const env = [];
|
||||
const body = [];
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*=.*)$/);
|
||||
if (m) {
|
||||
env.push(m[1]);
|
||||
} else if (/^[A-Za-z_][A-Za-z0-9_]*=\S+$/.test(line)) {
|
||||
env.push(line);
|
||||
} else {
|
||||
body.push(line);
|
||||
}
|
||||
}
|
||||
raw = [...env, ...body].join(' ');
|
||||
}
|
||||
return raw
|
||||
.replace(/MODEL_FILE=\$\(\{\s+/g, 'MODEL_FILE=$({ ')
|
||||
.replace(/\s+\}\s+\|\s+head\s+-1\)/g, ' } | head -1)')
|
||||
.replace(/\s*;\s*/g, '; ')
|
||||
@@ -567,7 +628,13 @@ function _selectedServeTarget(panel) {
|
||||
host = server?.host || '';
|
||||
}
|
||||
}
|
||||
const venv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || server?.envPath || _envState.envPath || '';
|
||||
const typedVenv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || '';
|
||||
// For remote targets the server profile is authoritative. Otherwise a stale
|
||||
// venv typed/loaded for another host can leak into this launch, e.g. a Linux
|
||||
// /home/... Python path being used on an Apple Silicon MLX server.
|
||||
const venv = host
|
||||
? (server?.envPath || typedVenv || '')
|
||||
: (typedVenv || server?.envPath || _envState.envPath || '');
|
||||
const label = host
|
||||
? (server?.name ? `${server.name} (${host})` : host)
|
||||
: (server?.name || 'local server');
|
||||
@@ -593,8 +660,8 @@ function _backendChoicesForTarget(target) {
|
||||
return [['llamacpp','llama.cpp'],['diffusers','Diffusers']];
|
||||
}
|
||||
return _isMetal()
|
||||
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['diffusers','Diffusers']];
|
||||
? [['mlx','MLX'],['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['mlx','MLX'],['diffusers','Diffusers']];
|
||||
}
|
||||
|
||||
async function _fetchServeRuntimePackage(panel, backend) {
|
||||
@@ -602,6 +669,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
|
||||
vllm: 'vllm',
|
||||
sglang: 'sglang',
|
||||
llamacpp: 'llama_cpp',
|
||||
mlx: 'mlx_lm',
|
||||
diffusers: 'diffusers',
|
||||
};
|
||||
const packageName = packageByBackend[backend];
|
||||
@@ -621,7 +689,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
|
||||
}
|
||||
|
||||
function _runtimeNoteText(backend, pkg, target) {
|
||||
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', diffusers: 'Diffusers' };
|
||||
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', mlx: 'MLX', diffusers: 'Diffusers' };
|
||||
const label = labels[backend] || backend;
|
||||
if (!pkg) return `${label} readiness unavailable for ${target.label}.`;
|
||||
const note = pkg.status_note || pkg.update_note || '';
|
||||
@@ -1109,7 +1177,14 @@ function _rerenderCachedModels() {
|
||||
const repo = item.dataset.repo;
|
||||
if (!repo) return;
|
||||
const m = allModels.find(x => x.repo_id === repo);
|
||||
if (!m || m.status !== 'ready') return;
|
||||
if (!m) return;
|
||||
if (m.status !== 'ready') {
|
||||
if (m.status === 'downloading' && !_isActivelyDownloading(m.repo_id)) {
|
||||
uiModule.showToast?.('Refreshing cached model status…');
|
||||
_fetchCachedModels(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle — close if already open
|
||||
if (item.classList.contains('doclib-card-expanded')) {
|
||||
@@ -1266,7 +1341,7 @@ function _rerenderCachedModels() {
|
||||
// stays as the source-of-truth so every existing change handler
|
||||
// (updateBackendVisibility, runtime readiness, command builder)
|
||||
// still fires via dispatchEvent('change') on selection.
|
||||
panelHtml += `<label>${_l('Engine','Inference engine: vLLM, SGLang, llama.cpp, Ollama, or Diffusers')}<div class="hwfit-backend-picker" data-backend-picker style="position:relative;width:100%;"><select class="hwfit-sf hwfit-backend-source" data-field="backend" style="display:none;">${backendOpts}</select><button type="button" class="hwfit-backend-btn" data-backend-btn aria-haspopup="listbox" aria-expanded="false" style="display:flex;align-items:center;gap:6px;width:100%;height:32px;padding:0 8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;cursor:pointer;text-align:left;position:relative;top:-4px;"><span class="hwfit-backend-btn-icon" data-backend-icon-slot aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--accent, var(--red));flex-shrink:0;"></span><span class="hwfit-backend-btn-label" data-backend-label style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.6;flex-shrink:0;"><polyline points="6 9 12 15 18 9"/></svg></button><div class="hwfit-backend-menu" data-backend-menu role="listbox" hidden style="position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:100;background:var(--panel, var(--bg));border:1px solid var(--border);border-radius:6px;box-shadow:0 6px 20px rgba(0,0,0,0.22);padding:4px;"></div></div></label>`;
|
||||
panelHtml += `<label>${_l('Engine','Inference engine: MLX, vLLM, SGLang, llama.cpp, Ollama, or Diffusers')}<div class="hwfit-backend-picker" data-backend-picker style="position:relative;width:100%;"><select class="hwfit-sf hwfit-backend-source" data-field="backend" style="display:none;">${backendOpts}</select><button type="button" class="hwfit-backend-btn" data-backend-btn aria-haspopup="listbox" aria-expanded="false" style="display:flex;align-items:center;gap:6px;width:100%;height:32px;padding:0 8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;cursor:pointer;text-align:left;position:relative;top:-4px;"><span class="hwfit-backend-btn-icon" data-backend-icon-slot aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--accent, var(--red));flex-shrink:0;"></span><span class="hwfit-backend-btn-label" data-backend-label style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.6;flex-shrink:0;"><polyline points="6 9 12 15 18 9"/></svg></button><div class="hwfit-backend-menu" data-backend-menu role="listbox" hidden style="position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:100;background:var(--panel, var(--bg));border:1px solid var(--border);border-radius:6px;box-shadow:0 6px 20px rgba(0,0,0,0.22);padding:4px;"></div></div></label>`;
|
||||
panelHtml += `<input type="hidden" class="hwfit-sf" data-field="host" value="${esc(_es.remoteHost || '')}" />`;
|
||||
// Inference mode pill (llama.cpp only) — lives directly to the
|
||||
// RIGHT of Backend in Row 1 so the engine and the GPU/CPU choice
|
||||
@@ -1328,13 +1403,13 @@ function _rerenderCachedModels() {
|
||||
// TP / Context / GPU / GPU Mem / Max Seqs / Dtype. Everything else
|
||||
// (Swap, KV Cache, Attention backend, Env vars, llama.cpp batch/ubatch)
|
||||
// moved to the Advanced fold below to keep this row scannable.
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-serve-row-core hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp hwfit-backend-ollama">`;
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-serve-row-core hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp hwfit-backend-ollama hwfit-backend-mlx">`;
|
||||
// Order: Dtype → TP → Context → Max Seqs → GPUs → GPU Mem.
|
||||
// Dtype moved down from Row 1 to make space for the Inference pill
|
||||
// (llama.cpp GPU/CPU toggle, llamacpp-only). GPUs lives next to
|
||||
// GPU Mem so "which devices + how much" sit adjacent. Max Seqs
|
||||
// follows Context per the "request-shape" cluster.
|
||||
panelHtml += `<label>${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp">${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('TP','Tensor Parallelism — split model across N GPUs')}<select class="hwfit-sf" data-field="tp">${tpOpts}</select></label>`;
|
||||
// ctx resets to the model's max on every panel open (the real ctx slider
|
||||
// lives in the Scan/Download toolbar — see cookbook.js .hwfit-ctx-control).
|
||||
@@ -1405,7 +1480,9 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `<label>Width${_h('Default output width')} <input type="text" class="hwfit-sf" data-field="diff_width" value="${esc(sv('diff_width', ''))}" placeholder="1024" /></label>`;
|
||||
panelHtml += `<label>Height${_h('Default output height')} <input type="text" class="hwfit-sf" data-field="diff_height" value="${esc(sv('diff_height', ''))}" placeholder="1024" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 3: Checkboxes (vLLM)
|
||||
// Row 3: Advanced toggles for vLLM/SGLang. Several concepts overlap,
|
||||
// but the actual flags differ; keep labels backend-neutral where a
|
||||
// shared checkbox maps to different runtime flags.
|
||||
// Order: Trust Remote → Auto Tool → Reasoning Parser (when the
|
||||
// model has one) → Enforce Eager → Prefix Caching. Reasoning
|
||||
// Parser was previously in a separate row below; the user wanted
|
||||
@@ -1416,21 +1493,22 @@ function _rerenderCachedModels() {
|
||||
const _rp_flag = _opts2_row3.flags.find(f => f.includes('--reasoning-parser'));
|
||||
const _rp_name = _rp_flag ? _rp_flag.split(' ')[1] : '';
|
||||
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-vllm hwfit-backend-sglang">`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="trust_remote"${sv('trust_remote',_isMiniMaxMSeries)?' checked':''} /> Trust Remote Code${_h('Allow model to run custom code from HuggingFace')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="auto_tool"${sv('auto_tool',_nativeToolDefault)?' checked':''} /> Auto Tool Choice${_h('Enable function/tool calling for agent mode')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="trust_remote"${sv('trust_remote',_isMiniMaxMSeries)?' checked':''} /> Trust Remote Code${_h('SGLang/vLLM: allow model code from HuggingFace via --trust-remote-code')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="auto_tool"${sv('auto_tool',_nativeToolDefault)?' checked':''} /> Auto Tool Choice${_h('SGLang/vLLM: enable native tool calling and auto-pick the detected tool-call parser')}</label>`;
|
||||
// Always-render the Reasoning Parser, Expert Parallel, and MoE Env
|
||||
// checkboxes — the model-family detection above is a hint, not a
|
||||
// hard gate. User asked to keep these visible regardless so that
|
||||
// a borderline-undetected MoE/reasoning model can still toggle
|
||||
// them without dropping back to the raw command box.
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="reasoning_parser" data-parser="${_rp_name || ''}"${sv('reasoning_parser',_reasoningDefault)?' checked':''} /> Reasoning Parser${_rp_name ? ` <span class="hwfit-parser-tag">${_rp_name}</span>` : ''}${_h('Splits <think> tokens into a separate channel. The tag (when shown) is the auto-detected parser; edit the command if you need a different one.')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="enforce_eager"${sv('enforce_eager',false)?' checked':''} /> Enforce Eager${_h('Disable CUDA graphs. Slower but uses less memory')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="prefix_cache"${sv('prefix_cache',false)?' checked':''} /> Prefix Caching${_h('Cache shared prompt prefixes across requests')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="reasoning_parser" data-parser="${_rp_name || ''}"${sv('reasoning_parser',_reasoningDefault)?' checked':''} /> Reasoning Parser${_rp_name ? ` <span class="hwfit-parser-tag">${_rp_name}</span>` : ''}${_h('SGLang/vLLM: splits thinking tokens into a reasoning channel using the detected parser.')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="enforce_eager"${sv('enforce_eager',false)?' checked':''} /> Disable CUDA Graphs${_h('vLLM: --enforce-eager. SGLang: --disable-cuda-graph. Slower, but useful for graph-capture crashes.')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="prefix_cache"${sv('prefix_cache',false)?' checked':''} /> Prefix / Radix Cache${_h('vLLM: prefix caching. SGLang: RadixAttention prefix cache; when off Odysseus adds --disable-radix-cache.')}</label>`;
|
||||
// Inline the previously-second vLLM checks row so Expert Parallel /
|
||||
// Speculative / MoE Env sit next to Prefix Caching with no gap. All
|
||||
// three are vLLM-only — class-gated so they hide on SGLang. Always
|
||||
// render so the user can flip them on for any MoE model.
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="expert_parallel"${sv('expert_parallel',_expertParallelDefault)?' checked':''} /> Expert Parallel${_h('MoE: shard expert layers across GPUs. Helps for MiniMax M-series, StepFun Step-3, Qwen3 A3B/A10B/A22B MoE, DeepSeek V3+/R1. Ignored / wasteful on dense models.')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="expert_parallel"${sv('expert_parallel',_expertParallelDefault)?' checked':''} /> Expert Parallel${_h('SGLang/vLLM MoE: shard expert layers across GPUs. Useful for DeepSeek/MiniMax/Qwen MoE; avoid on dense models.')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-sglang">Decode Graph${_h('SGLang only: tune decode CUDA graph capture. Smaller batch can fix DeepSeek-V4 graph-capture errors; disabled is safest but slower.')} <select class="hwfit-sf" data-field="sglang_decode_graph" style="height:24px;max-width:92px;"><option value=""${sv('sglang_decode_graph','') === '' ? ' selected' : ''}>auto</option><option value="bs16"${sv('sglang_decode_graph','') === 'bs16' ? ' selected' : ''}>bs 16</option><option value="disabled"${sv('sglang_decode_graph','') === 'disabled' ? ' selected' : ''}>off</option></select></label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="language_model_only"${sv('language_model_only',_isMiniMaxM3)?' checked':''} /> Language Model Only${_h('vLLM --language-model-only. Needed by MiniMax M3 text serving when the repo also contains VL components.')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="disable_custom_all_reduce"${sv('disable_custom_all_reduce',_isMiniMaxM3)?' checked':''} /> Disable Custom All Reduce${_h('vLLM --disable-custom-all-reduce. Useful for some 8-GPU/nightly configurations.')}</label>`;
|
||||
{
|
||||
@@ -1585,6 +1663,7 @@ function _rerenderCachedModels() {
|
||||
const buildTarget = _selectedServeTarget(panel);
|
||||
f.host = buildTarget.host || '';
|
||||
f.platform = buildTarget.platform || '';
|
||||
f.venv = buildTarget.venv || '';
|
||||
const hostField = panel.querySelector('[data-field="host"]');
|
||||
if (hostField) hostField.value = f.host;
|
||||
const backend = f.backend || 'vllm';
|
||||
@@ -1871,6 +1950,7 @@ function _rerenderCachedModels() {
|
||||
const _BACKEND_GLYPHS = {
|
||||
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
|
||||
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
|
||||
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
|
||||
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
@@ -1984,7 +2064,7 @@ function _rerenderCachedModels() {
|
||||
const backend = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
|
||||
const noteText = note.querySelector('.hwfit-serve-runtime-text');
|
||||
const _writeNote = (s) => { if (noteText) noteText.textContent = s; else note.textContent = s; };
|
||||
if (!['vllm', 'sglang', 'llamacpp', 'diffusers'].includes(backend)) {
|
||||
if (!['vllm', 'sglang', 'llamacpp', 'mlx', 'diffusers'].includes(backend)) {
|
||||
note.style.display = 'none';
|
||||
_writeNote('');
|
||||
return;
|
||||
@@ -2024,7 +2104,7 @@ function _rerenderCachedModels() {
|
||||
// recipe panel for this backend so the user has one click
|
||||
// to the fix instead of hunting for the right row.
|
||||
if (noteText) {
|
||||
const pkgName = pkg?.name || ({ vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', diffusers: 'diffusers' }[backend]);
|
||||
const pkgName = pkg?.name || ({ vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm', diffusers: 'diffusers' }[backend]);
|
||||
const repo = (panel.closest('.doclib-card, .memory-item')?.dataset?.repo) || '';
|
||||
const link = document.createElement('a');
|
||||
link.href = '#';
|
||||
@@ -2079,7 +2159,7 @@ function _rerenderCachedModels() {
|
||||
});
|
||||
} else {
|
||||
const fields = {
|
||||
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm',
|
||||
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('mlx_lm.server') ? 'mlx' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm',
|
||||
port: _ex(/--port\s+(\d+)/) || '8000',
|
||||
tp: _ex(/--tensor-parallel-size\s+(\d+)/) || '1',
|
||||
ctx: _ex(/--max-model-len\s+(\d+)/) || _ex(/--n_ctx\s+(\d+)/) || _ex(/-c\s+(\d+)/) || '8192',
|
||||
@@ -3771,7 +3851,8 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
const modelDirs = [];
|
||||
if (selectedServer && Array.isArray(selectedServer.modelDirs)) {
|
||||
for (const d of selectedServer.modelDirs) {
|
||||
if (d && d !== '~/.cache/huggingface/hub') modelDirs.push(d);
|
||||
const normalized = _normalizeCookbookModelDir(d);
|
||||
if (normalized && normalized !== '~/.cache/huggingface/hub') modelDirs.push(normalized);
|
||||
}
|
||||
}
|
||||
// Sync the header dir pills to THIS server (the one whose models we're listing).
|
||||
@@ -3783,7 +3864,7 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
const _allDirs = (Array.isArray(selectedServer.modelDirs) && selectedServer.modelDirs.length
|
||||
? selectedServer.modelDirs
|
||||
: [selectedServer.modelDir || '~/.cache/huggingface/hub'])
|
||||
.map(d => (d || '').replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
|
||||
.map(d => _normalizeCookbookModelDir(d)).filter(Boolean);
|
||||
_dirsEl.innerHTML = _allDirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('')
|
||||
+ '<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>';
|
||||
_dirsEl.querySelector('.cookbook-serve-dir-edit')?.addEventListener('click', () => {
|
||||
@@ -3803,10 +3884,12 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
}
|
||||
if (!allowNetwork) {
|
||||
_dlWp.destroy();
|
||||
list.innerHTML = '<div class="hwfit-loading" style="flex-direction:column;gap:8px;text-align:center;"><div>No cached model scan yet</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Check this server\'s model cache.</div><button type="button" class="hwfit-gpu-btn serve-empty-scan-btn" style="height:26px;padding:3px 10px;">Scan</button></div>';
|
||||
list.querySelector('.serve-empty-scan-btn')?.addEventListener('click', () => {
|
||||
_fetchCachedModels(true);
|
||||
});
|
||||
const wp = spinnerModule.createWhirlpool(22);
|
||||
list.innerHTML = '<div class="hwfit-loading serve-empty-auto-scan" style="flex-direction:column;gap:8px;text-align:center;"><div class="serve-empty-auto-wp"></div><div>No cached model scan yet</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Scanning this server\'s model cache…</div></div>';
|
||||
list.querySelector('.serve-empty-auto-wp')?.appendChild(wp.element);
|
||||
setTimeout(() => {
|
||||
if (list.querySelector('.serve-empty-auto-scan')) _fetchCachedModels(true);
|
||||
}, 60);
|
||||
const tagContainer = document.getElementById('serve-tags');
|
||||
if (tagContainer) tagContainer.innerHTML = '';
|
||||
return;
|
||||
|
||||
+450
-38
@@ -2288,6 +2288,61 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
return header + '\n---\n' + body;
|
||||
}
|
||||
|
||||
function _looksLikeWrappedEmailContent(text) {
|
||||
const t = String(text || '').replace(/\r\n/g, '\n').trim();
|
||||
return /\n---\n/.test(t) && /^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder):\s*/im.test(t);
|
||||
}
|
||||
|
||||
function _decodeBase64EmailWrapper(block) {
|
||||
const compact = String(block || '').replace(/\s+/g, '');
|
||||
if (compact.length < 24 || compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) return null;
|
||||
try {
|
||||
const bin = atob(compact);
|
||||
let decoded = '';
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
|
||||
decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
} else {
|
||||
decoded = decodeURIComponent(escape(bin));
|
||||
}
|
||||
decoded = decoded.replace(/\r\n/g, '\n');
|
||||
return _looksLikeWrappedEmailContent(decoded) ? decoded : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _sanitizeOutgoingEmailBody(raw) {
|
||||
let text = String(raw || '').replace(/\r\n/g, '\n');
|
||||
const trimmed = text.trim();
|
||||
const decodedWhole = _decodeBase64EmailWrapper(trimmed);
|
||||
if (decodedWhole) text = _parseEmailHeader(decodedWhole).body || '';
|
||||
else if (_looksLikeWrappedEmailContent(trimmed)) text = _parseEmailHeader(trimmed).body || '';
|
||||
|
||||
const parts = text.split(/(\n{2,})/);
|
||||
let changed = false;
|
||||
const clean = parts.map(part => {
|
||||
if (/^\n+$/.test(part)) return part;
|
||||
const decoded = _decodeBase64EmailWrapper(part);
|
||||
if (!decoded) return part;
|
||||
changed = true;
|
||||
return _parseEmailHeader(decoded).body || '';
|
||||
}).join('');
|
||||
|
||||
if (!changed && /<[^>]+>/.test(text) && typeof document !== 'undefined') {
|
||||
const probe = document.createElement('div');
|
||||
probe.innerHTML = text;
|
||||
const plain = (probe.innerText || probe.textContent || '').trim();
|
||||
const plainClean = plain ? _sanitizeOutgoingEmailBody(plain) : plain;
|
||||
if (plainClean !== plain) return plainClean;
|
||||
}
|
||||
|
||||
return (changed ? clean : text)
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function _emailLocalDraftKey(sourceUid, sourceFolder, inReplyTo) {
|
||||
const uid = String(sourceUid || '').trim();
|
||||
if (!uid) return '';
|
||||
@@ -2328,7 +2383,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
references: draft.references ?? fields.references,
|
||||
sourceUid: draft.sourceUid ?? fields.sourceUid,
|
||||
sourceFolder: draft.sourceFolder ?? fields.sourceFolder,
|
||||
body: draft.body ?? fields.body,
|
||||
body: _sanitizeOutgoingEmailBody(draft.body ?? fields.body),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2378,20 +2433,27 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
}
|
||||
|
||||
// ── WYSIWYG email body helpers ──
|
||||
function _emailPlainTextToHtml(text) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = text == null ? '' : String(text);
|
||||
return d.innerHTML.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function _emailBodyToHtml(text) {
|
||||
const t = (text || '').trim();
|
||||
if (!t) return '';
|
||||
// If it already contains a formatting/structural HTML tag, it's a saved
|
||||
// WYSIWYG body — use it verbatim. (Checking a leading '<' isn't enough: a
|
||||
// WYSIWYG body — sanitize it before rendering. (Checking a leading '<' isn't enough: a
|
||||
// rich body often starts with plain text, e.g. "Hi <b>there</b>".)
|
||||
if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) return t;
|
||||
if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) {
|
||||
return markdownModule.sanitizeAllowedHtml
|
||||
? markdownModule.sanitizeAllowedHtml(t)
|
||||
: _emailPlainTextToHtml(t);
|
||||
}
|
||||
// Email body: keep author-typed `:shortcode:` text literal. Issue #345
|
||||
// (shortcode → emoji) is scoped to chat; do not rewrite colons in mail.
|
||||
try { return markdownModule.mdToHtml(text, { shortcodes: false }); }
|
||||
catch (_) {
|
||||
const d = document.createElement('div'); d.textContent = text;
|
||||
return d.innerHTML.replace(/\n/g, '<br>');
|
||||
}
|
||||
catch (_) { return _emailPlainTextToHtml(text); }
|
||||
}
|
||||
// Mirror the rich body's plain text into the hidden textarea so the existing
|
||||
// send / draft / change-detection plumbing (which reads the textarea) stays
|
||||
@@ -2629,6 +2691,15 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
function _splitEmailReplyQuote(text) {
|
||||
const original = String(text || '');
|
||||
if (!original) return { body: '', quote: '', stripped: false };
|
||||
const literal = '---------- Previous message ----------';
|
||||
const literalIdx = original.indexOf(literal);
|
||||
if (literalIdx >= 0) {
|
||||
return {
|
||||
body: original.slice(0, literalIdx).trim(),
|
||||
quote: original.slice(literalIdx).trim(),
|
||||
stripped: true,
|
||||
};
|
||||
}
|
||||
const htmlQuoteOffset = _emailQuoteStartOffset(original);
|
||||
if (htmlQuoteOffset >= 0) {
|
||||
const body = original.slice(0, htmlQuoteOffset).trim();
|
||||
@@ -2749,7 +2820,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
if (_shouldAutoCollapseEmailHeader()) _setEmailHeaderCollapsed(true, { manual: false });
|
||||
}
|
||||
|
||||
function _showEmailFields(doc) {
|
||||
function _showEmailFields(doc, { applyLocalDraft = true } = {}) {
|
||||
const emailHeader = document.getElementById('doc-email-header');
|
||||
const emailActions = document.getElementById('doc-email-actions');
|
||||
// Show MD toolbar for email too (B, I, etc.)
|
||||
@@ -2782,11 +2853,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
document.getElementById('doc-editor-code')?.classList.add('email-mode');
|
||||
document.getElementById('doc-editor-highlight')?.classList.add('email-mode');
|
||||
let fields = _parseEmailHeader(doc.content || '');
|
||||
fields = _emailFieldsWithLocalDraft(fields);
|
||||
if (applyLocalDraft) fields = _emailFieldsWithLocalDraft(fields);
|
||||
const preserveEmailHeader = !!(fields.sourceUid || fields.inReplyTo || fields.references);
|
||||
const subjectInput = document.getElementById('doc-email-subject');
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
_setEmailHeaderInputValue('doc-email-to', fields.to, { preserveNonEmpty: true });
|
||||
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveNonEmpty: true });
|
||||
_setEmailHeaderInputValue('doc-email-to', fields.to, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderCollapsed(!!(doc && doc._emailHeaderCollapsed), { manual: false });
|
||||
if (subjectInput && !subjectInput._emailTabBodyBound) {
|
||||
subjectInput._emailTabBodyBound = true;
|
||||
@@ -2797,10 +2869,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
}
|
||||
});
|
||||
}
|
||||
_setEmailHeaderInputValue('doc-email-in-reply-to', fields.inReplyTo, { preserveNonEmpty: true });
|
||||
_setEmailHeaderInputValue('doc-email-references', fields.references, { preserveNonEmpty: true });
|
||||
_setEmailHeaderInputValue('doc-email-source-uid', fields.sourceUid || '', { preserveNonEmpty: true });
|
||||
_setEmailHeaderInputValue('doc-email-source-folder', fields.sourceFolder || '', { preserveNonEmpty: true });
|
||||
_setEmailHeaderInputValue('doc-email-in-reply-to', fields.inReplyTo, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-references', fields.references, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-source-uid', fields.sourceUid || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-source-folder', fields.sourceFolder || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
// Show/hide unread button only if we have a source UID (came from inbox)
|
||||
const unreadBtn = document.getElementById('doc-email-unread-btn');
|
||||
if (unreadBtn) unreadBtn.style.display = fields.sourceUid ? '' : 'none';
|
||||
@@ -2923,8 +2995,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const ccRow = document.getElementById('doc-email-cc-row');
|
||||
const bccRow = document.getElementById('doc-email-bcc-row');
|
||||
const ccToggle = document.getElementById('doc-email-show-cc');
|
||||
_setEmailHeaderInputValue('doc-email-cc', fields.cc || '', { preserveNonEmpty: true });
|
||||
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveNonEmpty: true });
|
||||
_setEmailHeaderInputValue('doc-email-cc', fields.cc || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
const hasCcBcc = !!(
|
||||
fields.cc ||
|
||||
fields.bcc ||
|
||||
@@ -3055,6 +3127,292 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
await _uploadComposeFiles(files);
|
||||
}
|
||||
|
||||
let _odysseusAttachMenu = null;
|
||||
|
||||
function _closeOdysseusAttachMenu() {
|
||||
if (_odysseusAttachMenu) {
|
||||
_odysseusAttachMenu.remove();
|
||||
_odysseusAttachMenu = null;
|
||||
}
|
||||
document.removeEventListener('click', _attachMenuOutsideClick, true);
|
||||
document.removeEventListener('keydown', _attachMenuEscape, true);
|
||||
}
|
||||
|
||||
function _attachMenuOutsideClick(e) {
|
||||
if (_odysseusAttachMenu && !_odysseusAttachMenu.contains(e.target)) _closeOdysseusAttachMenu();
|
||||
}
|
||||
|
||||
function _attachMenuEscape(e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
_closeOdysseusAttachMenu();
|
||||
}
|
||||
|
||||
function _positionOdysseusAttachMenu(anchor, menu) {
|
||||
const r = anchor?.getBoundingClientRect?.();
|
||||
if (!r) return;
|
||||
menu.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - 310))}px`;
|
||||
menu.style.top = `${r.bottom + 6}px`;
|
||||
requestAnimationFrame(() => {
|
||||
const mr = menu.getBoundingClientRect();
|
||||
if (mr.bottom > window.innerHeight - 8) {
|
||||
menu.style.top = `${Math.max(8, r.top - mr.height - 6)}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _odysseusAttachLabel(item, kind) {
|
||||
if (kind === 'gallery') {
|
||||
return item.caption || item.prompt || item.filename || 'Gallery image';
|
||||
}
|
||||
return item.title || 'Untitled document';
|
||||
}
|
||||
|
||||
async function _stageOdysseusAttachment(kind, id) {
|
||||
const doc = docs.get(activeDocId);
|
||||
if (!doc || doc.language !== 'email') return null;
|
||||
if (!doc._composeAtts) doc._composeAtts = [];
|
||||
const res = await fetch(`${API_BASE}/api/email/compose-from-odysseus`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind, id }),
|
||||
});
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (!res.ok || !data?.success) throw new Error(data?.error || data?.detail || `HTTP ${res.status}`);
|
||||
doc._composeAtts.push({
|
||||
token: data.token,
|
||||
filename: data.filename,
|
||||
size: data.size || 0,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function _stageOdysseusZip(items) {
|
||||
const doc = docs.get(activeDocId);
|
||||
if (!doc || doc.language !== 'email') return null;
|
||||
if (!doc._composeAtts) doc._composeAtts = [];
|
||||
const res = await fetch(`${API_BASE}/api/email/compose-from-odysseus-zip`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (!res.ok || !data?.success) throw new Error(data?.error || data?.detail || `HTTP ${res.status}`);
|
||||
doc._composeAtts.push({
|
||||
token: data.token,
|
||||
filename: data.filename,
|
||||
size: data.size || 0,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function _afterOdysseusAttachmentsAdded(count, label) {
|
||||
_renderComposeAttachments();
|
||||
clearTimeout(_autoSaveDebounce);
|
||||
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
|
||||
if (uiModule) uiModule.showToast(count > 1 ? `Attached ${count} items` : `Attached ${label || 'item'}`);
|
||||
}
|
||||
|
||||
async function _attachOdysseusItem(kind, id, label, opts = {}) {
|
||||
try {
|
||||
const data = await _stageOdysseusAttachment(kind, id);
|
||||
if (!data) return;
|
||||
_afterOdysseusAttachmentsAdded(1, label || data.filename);
|
||||
if (!opts.keepOpen) _closeOdysseusAttachMenu();
|
||||
} catch (err) {
|
||||
console.error('Failed to attach Odysseus item:', err);
|
||||
if (uiModule) uiModule.showError('Failed to attach from Odysseus');
|
||||
}
|
||||
}
|
||||
|
||||
function _selectedOdysseusAttachRows(menu) {
|
||||
return Array.from(menu?.querySelectorAll?.('.email-odysseus-attach-row.is-selected') || []);
|
||||
}
|
||||
|
||||
function _syncOdysseusAttachSelection(menu) {
|
||||
const selected = _selectedOdysseusAttachRows(menu);
|
||||
const bar = menu?.querySelector?.('.email-odysseus-attach-actions');
|
||||
const count = menu?.querySelector?.('.email-odysseus-attach-count');
|
||||
const attachBtn = menu?.querySelector?.('.email-odysseus-attach-selected');
|
||||
if (bar) bar.style.display = '';
|
||||
if (count) count.textContent = selected.length ? `${selected.length} selected` : 'Select items to attach';
|
||||
if (attachBtn) attachBtn.disabled = selected.length === 0;
|
||||
}
|
||||
|
||||
async function _attachSelectedOdysseusItems(menu) {
|
||||
const rows = _selectedOdysseusAttachRows(menu);
|
||||
if (!rows.length) return;
|
||||
const btn = menu.querySelector('.email-odysseus-attach-selected');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add('is-loading');
|
||||
}
|
||||
let added = 0;
|
||||
try {
|
||||
const items = rows.map(row => ({ kind: row.dataset.kind, id: row.dataset.id })).filter(x => x.kind && x.id);
|
||||
let zip = false;
|
||||
if (items.length > 5) {
|
||||
const ask = window.styledConfirm || uiModule?.styledConfirm;
|
||||
zip = ask
|
||||
? await ask(`Attach ${items.length} files as one zip?`, { confirmText: 'Zip', cancelText: 'Separate' })
|
||||
: window.confirm(`Attach ${items.length} files as one zip?`);
|
||||
}
|
||||
if (zip) {
|
||||
await _stageOdysseusZip(items);
|
||||
added = 1;
|
||||
} else {
|
||||
for (const item of items) {
|
||||
await _stageOdysseusAttachment(item.kind, item.id);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
_afterOdysseusAttachmentsAdded(added, zip ? 'odysseus-attachments.zip' : undefined);
|
||||
_closeOdysseusAttachMenu();
|
||||
} catch (err) {
|
||||
console.error('Failed to attach selected Odysseus items:', err);
|
||||
if (uiModule) uiModule.showError(added ? `Attached ${added}, then failed` : 'Failed to attach from Odysseus');
|
||||
_renderComposeAttachments();
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.classList.remove('is-loading');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _loadOdysseusAttachItems(menu, kind) {
|
||||
const list = menu.querySelector('.email-odysseus-attach-list');
|
||||
if (!list) return;
|
||||
menu.dataset.odyAttachKind = kind;
|
||||
list.replaceChildren(spinnerModule.createLoadingRow('Loading…', 14));
|
||||
menu.querySelectorAll('[data-ody-attach-kind]').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.odyAttachKind === kind);
|
||||
});
|
||||
const q = (menu.querySelector('.email-odysseus-attach-search')?.value || '').trim();
|
||||
try {
|
||||
const params = new URLSearchParams({ sort: 'recent', limit: '20' });
|
||||
if (q) params.set('search', q);
|
||||
const endpoint = kind === 'gallery'
|
||||
? `${API_BASE}/api/gallery/library?${params}`
|
||||
: `${API_BASE}/api/documents/library?${params}`;
|
||||
const res = await fetch(endpoint, { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || data?.detail || `HTTP ${res.status}`);
|
||||
const items = kind === 'gallery'
|
||||
? (Array.isArray(data?.items) ? data.items : Array.isArray(data?.images) ? data.images : [])
|
||||
: (Array.isArray(data?.documents) ? data.documents : Array.isArray(data?.items) ? data.items : []);
|
||||
if (!items.length) {
|
||||
list.innerHTML = `<div class="email-odysseus-attach-empty">${q ? 'No matches' : `No ${kind === 'gallery' ? 'images' : 'documents'}`}</div>`;
|
||||
_syncOdysseusAttachSelection(menu);
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
for (const item of items) {
|
||||
const label = _odysseusAttachLabel(item, kind);
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = `email-odysseus-attach-row ${kind === 'gallery' ? 'is-gallery' : ''}`;
|
||||
row.dataset.id = item.id || '';
|
||||
row.dataset.kind = kind;
|
||||
if (kind === 'gallery') {
|
||||
const src = item.url ? `${API_BASE}${item.url}` : '';
|
||||
row.innerHTML = `
|
||||
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
|
||||
<span class="email-odysseus-attach-thumb">${src ? `<img src="${_escHtml(src)}" alt="">` : ''}</span>
|
||||
<span class="email-odysseus-attach-main">
|
||||
<span class="email-odysseus-attach-title">${_escHtml(label)}</span>
|
||||
<span class="email-odysseus-attach-meta">${_escHtml(item.filename || 'image')}</span>
|
||||
</span>
|
||||
`;
|
||||
} else {
|
||||
row.innerHTML = `
|
||||
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
|
||||
<span class="email-odysseus-attach-icon">${langIcon(item.language || 'text', 14, { style: 'opacity:0.8;' })}</span>
|
||||
<span class="email-odysseus-attach-main">
|
||||
<span class="email-odysseus-attach-title">${_escHtml(label)}</span>
|
||||
<span class="email-odysseus-attach-meta">${_escHtml(item.language || 'text')}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
row.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
row.classList.toggle('is-selected');
|
||||
_syncOdysseusAttachSelection(menu);
|
||||
});
|
||||
row.addEventListener('dblclick', () => _attachOdysseusItem(kind, item.id, label, { keepOpen: false }));
|
||||
list.appendChild(row);
|
||||
}
|
||||
_syncOdysseusAttachSelection(menu);
|
||||
} catch (err) {
|
||||
console.error('Failed to load Odysseus attach items:', err);
|
||||
list.innerHTML = '<div class="email-odysseus-attach-empty">Could not load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function _showComposeAttachMenu(anchor) {
|
||||
if (_activeDocLanguage() !== 'email') {
|
||||
document.getElementById('doc-md-image-input')?.click();
|
||||
return;
|
||||
}
|
||||
_closeOdysseusAttachMenu();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'email-odysseus-attach-menu';
|
||||
menu.innerHTML = `
|
||||
<button type="button" class="email-odysseus-attach-local">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Upload file
|
||||
</button>
|
||||
<div class="email-odysseus-attach-tabs">
|
||||
<button type="button" data-ody-attach-kind="document" class="active">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h6"/></svg>
|
||||
<span>Documents</span>
|
||||
</button>
|
||||
<button type="button" data-ody-attach-kind="gallery">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
||||
<span>Gallery</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="email-odysseus-attach-search-wrap">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<input type="search" class="email-odysseus-attach-search" placeholder="Search attachments">
|
||||
</label>
|
||||
<div class="email-odysseus-attach-list"></div>
|
||||
<div class="email-odysseus-attach-actions">
|
||||
<span class="email-odysseus-attach-count"></span>
|
||||
<button type="button" class="email-odysseus-attach-selected" disabled>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>
|
||||
<span>Attach</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(menu);
|
||||
_odysseusAttachMenu = menu;
|
||||
_positionOdysseusAttachMenu(anchor, menu);
|
||||
menu.querySelector('.email-odysseus-attach-local')?.addEventListener('click', () => {
|
||||
_closeOdysseusAttachMenu();
|
||||
document.getElementById('doc-email-file-input')?.click();
|
||||
});
|
||||
menu.querySelectorAll('[data-ody-attach-kind]').forEach(btn => {
|
||||
btn.addEventListener('click', () => _loadOdysseusAttachItems(menu, btn.dataset.odyAttachKind));
|
||||
});
|
||||
let attachSearchTimer = null;
|
||||
menu.querySelector('.email-odysseus-attach-search')?.addEventListener('input', () => {
|
||||
clearTimeout(attachSearchTimer);
|
||||
attachSearchTimer = setTimeout(() => {
|
||||
_loadOdysseusAttachItems(menu, menu.dataset.odyAttachKind || 'document');
|
||||
}, 220);
|
||||
});
|
||||
menu.querySelector('.email-odysseus-attach-selected')?.addEventListener('click', () => _attachSelectedOdysseusItems(menu));
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', _attachMenuOutsideClick, true);
|
||||
document.addEventListener('keydown', _attachMenuEscape, true);
|
||||
}, 0);
|
||||
_loadOdysseusAttachItems(menu, 'document');
|
||||
}
|
||||
|
||||
function _isMarkdownImageFile(file) {
|
||||
if (!file) return false;
|
||||
if ((file.type || '').toLowerCase().startsWith('image/')) return true;
|
||||
@@ -3241,13 +3599,23 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
}).filter(Boolean)
|
||||
);
|
||||
sugg.innerHTML = '';
|
||||
sugg.dataset.navStarted = '0';
|
||||
let count = 0;
|
||||
for (const c of data.results) {
|
||||
for (const em of (c.emails || [])) {
|
||||
if (already.has(em.toLowerCase())) continue;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'contact-suggestion';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('aria-selected', 'false');
|
||||
item.innerHTML = `<span class="contact-name">${_escHtml(c.name)}</span><span class="contact-email">${_escHtml(em)}</span>`;
|
||||
item.addEventListener('mouseenter', () => {
|
||||
sugg.dataset.navStarted = '1';
|
||||
sugg.querySelectorAll('.contact-suggestion').forEach(it => {
|
||||
it.classList.toggle('active', it === item);
|
||||
it.setAttribute('aria-selected', it === item ? 'true' : 'false');
|
||||
});
|
||||
});
|
||||
// mousedown fires before blur so the click doesn't get lost
|
||||
item.addEventListener('mousedown', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); });
|
||||
item.addEventListener('click', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); });
|
||||
@@ -3256,9 +3624,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
}
|
||||
}
|
||||
if (count === 0) { sugg.style.display = 'none'; return; }
|
||||
// Auto-highlight first suggestion so Enter accepts it.
|
||||
const first = sugg.querySelector('.contact-suggestion');
|
||||
if (first) first.classList.add('active');
|
||||
sugg.style.display = '';
|
||||
} catch (e) {
|
||||
sugg.style.display = 'none';
|
||||
@@ -3284,16 +3649,32 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const items = open ? sugg.querySelectorAll('.contact-suggestion') : [];
|
||||
const active = open ? sugg.querySelector('.contact-suggestion.active') : null;
|
||||
let idx = active ? Array.from(items).indexOf(active) : -1;
|
||||
const setActive = (nextIdx) => {
|
||||
items.forEach((it, i) => {
|
||||
const on = i === nextIdx;
|
||||
it.classList.toggle('active', on);
|
||||
it.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
});
|
||||
if (items[nextIdx]) {
|
||||
items[nextIdx].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
};
|
||||
if (open && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
idx = Math.min(items.length - 1, idx + 1);
|
||||
items.forEach(it => it.classList.remove('active'));
|
||||
if (items[idx]) items[idx].classList.add('active');
|
||||
if (!items.length) return;
|
||||
if (sugg.dataset.navStarted !== '1') {
|
||||
idx = Math.max(0, idx);
|
||||
sugg.dataset.navStarted = '1';
|
||||
} else {
|
||||
idx = Math.min(items.length - 1, idx + 1);
|
||||
}
|
||||
setActive(idx);
|
||||
} else if (open && e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!items.length) return;
|
||||
sugg.dataset.navStarted = '1';
|
||||
idx = Math.max(0, idx - 1);
|
||||
items.forEach(it => it.classList.remove('active'));
|
||||
if (items[idx]) items[idx].classList.add('active');
|
||||
setActive(idx);
|
||||
} else if (e.key === 'Enter') {
|
||||
// If a suggestion is highlighted, commit it. Otherwise — if the
|
||||
// current fragment already looks like a complete email — commit
|
||||
@@ -3410,8 +3791,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const _rich = _emailRichbodyActive();
|
||||
if (_rich) _syncEmailRichbody(_rich);
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
|
||||
const bodyHtml = _rich ? _rich.innerHTML : null;
|
||||
const rawBody = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
|
||||
const body = _sanitizeOutgoingEmailBody(rawBody);
|
||||
let bodyHtml = _rich ? _rich.innerHTML : null;
|
||||
if (_rich && body !== rawBody) bodyHtml = _emailBodyToHtml(body);
|
||||
const doc = docs.get(activeDocId);
|
||||
const attachments = (doc?._composeAtts || []).map(a => a.token);
|
||||
if (!to || !body) {
|
||||
@@ -3574,8 +3957,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const _rich = _emailRichbodyActive();
|
||||
if (_rich) _syncEmailRichbody(_rich);
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
|
||||
const bodyHtml = _rich ? _rich.innerHTML : null;
|
||||
const rawBody = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
|
||||
const body = _sanitizeOutgoingEmailBody(rawBody);
|
||||
let bodyHtml = _rich ? _rich.innerHTML : null;
|
||||
if (_rich && body !== rawBody) bodyHtml = _emailBodyToHtml(body);
|
||||
const btn = document.getElementById('doc-email-draft-btn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; }
|
||||
const controller = new AbortController();
|
||||
@@ -3917,10 +4302,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const references = document.getElementById('doc-email-references')?.value?.trim();
|
||||
const _rich = _emailRichbodyActive();
|
||||
if (_rich) _syncEmailRichbody(_rich);
|
||||
const body = (_rich
|
||||
const rawBody = (_rich
|
||||
? (_rich.innerText || _rich.textContent || '')
|
||||
: (document.getElementById('doc-editor-textarea')?.value || '')
|
||||
).trim();
|
||||
const body = _sanitizeOutgoingEmailBody(rawBody);
|
||||
const doc = docs.get(activeDocId);
|
||||
const attachments = (doc?._composeAtts || []).map(a => a.token);
|
||||
|
||||
@@ -5139,12 +5525,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
}, true);
|
||||
|
||||
// Attachments
|
||||
document.getElementById('doc-email-attach-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('doc-email-file-input')?.click();
|
||||
document.getElementById('doc-email-attach-btn')?.addEventListener('click', (e) => {
|
||||
_showComposeAttachMenu(e.currentTarget);
|
||||
});
|
||||
document.getElementById('md-toolbar-attach-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('md-toolbar-attach-btn')?.addEventListener('click', (e) => {
|
||||
if (_activeDocLanguage() === 'email') {
|
||||
document.getElementById('doc-email-file-input')?.click();
|
||||
_showComposeAttachMenu(e.currentTarget);
|
||||
} else {
|
||||
document.getElementById('doc-md-image-input')?.click();
|
||||
}
|
||||
@@ -10056,11 +10442,33 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
if (!docs.has(docId)) {
|
||||
const curSession = sessionModule?.getCurrentSessionId() || '';
|
||||
let reuseId = null;
|
||||
const incomingFields = _parseEmailHeader(newContent || '');
|
||||
|
||||
// Email subjects repeat constantly ("test", "Re: ..."). Match open
|
||||
// compose docs by source email identity first; never let a same-title
|
||||
// draft steal an update meant for a different open email.
|
||||
if (incomingFields.sourceUid) {
|
||||
const wantFolder = (incomingFields.sourceFolder || 'INBOX').trim();
|
||||
for (const [existingId, existingDoc] of docs) {
|
||||
const existingFields = _parseEmailHeader(existingDoc.content || '');
|
||||
if (
|
||||
String(existingFields.sourceUid || '') === String(incomingFields.sourceUid)
|
||||
&& ((existingFields.sourceFolder || 'INBOX').trim() === wantFolder)
|
||||
) {
|
||||
reuseId = existingId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First: match by title
|
||||
if (data.title) {
|
||||
if (!reuseId && data.title) {
|
||||
for (const [existingId, existingDoc] of docs) {
|
||||
if (existingDoc.title === data.title && existingDoc.sessionId === curSession) {
|
||||
if (
|
||||
existingDoc.title === data.title
|
||||
&& existingDoc.sessionId === curSession
|
||||
&& (existingDoc.language || '').toLowerCase() !== 'email'
|
||||
) {
|
||||
reuseId = existingId;
|
||||
break;
|
||||
}
|
||||
@@ -10195,8 +10603,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
if (isEmailUpdate) {
|
||||
const updatedDocForEmail = docs.get(docId);
|
||||
if (updatedDocForEmail) {
|
||||
const updatedFields = _parseEmailHeader(updatedDocForEmail.content || '');
|
||||
_clearEmailLocalDraft(updatedFields.sourceUid, updatedFields.sourceFolder, updatedFields.inReplyTo);
|
||||
_setMarkdownPreviewActive(false, { remember: false });
|
||||
_showEmailFields(updatedDocForEmail);
|
||||
_showEmailFields(updatedDocForEmail, { applyLocalDraft: false });
|
||||
}
|
||||
} else {
|
||||
if (textarea) textarea.value = newContent;
|
||||
@@ -10219,7 +10629,9 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
if (isEmailUpdate && updatedDoc) {
|
||||
updatedDoc.language = 'email';
|
||||
if (langSelect) langSelect.value = 'email';
|
||||
_showEmailFields(updatedDoc);
|
||||
const updatedFields = _parseEmailHeader(updatedDoc.content || '');
|
||||
_clearEmailLocalDraft(updatedFields.sourceUid, updatedFields.sourceFolder, updatedFields.inReplyTo);
|
||||
_showEmailFields(updatedDoc, { applyLocalDraft: false });
|
||||
}
|
||||
if (updatedDoc && !updatedDoc.userSetLanguage && !updatedDoc.language) {
|
||||
setTimeout(attemptAutoDetect, 100);
|
||||
|
||||
+89
-93
@@ -8,7 +8,7 @@ import sessionModule from './sessions.js';
|
||||
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen } from './emailLibrary.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { applyEdgeDock } from './modalSnap.js';
|
||||
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
|
||||
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
|
||||
import { emailApiUrl, emailAccountQuery } from './emailShared.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
@@ -29,6 +29,23 @@ const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _replySeparator = '---------- Previous message ----------';
|
||||
const _DONE_RESPONSE_TAGS = new Set(['urgent', 'reply-soon', 'action-needed']);
|
||||
|
||||
function _splitEmailAddresses(raw) {
|
||||
return (typeof raw === 'string' ? raw : '')
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function _isMyEmailAddress(addr, myAddresses) {
|
||||
const email = extractEmail(addr);
|
||||
if (!email) return false;
|
||||
return new Set((myAddresses || []).map(a => String(a || '').trim().toLowerCase()).filter(Boolean)).has(email);
|
||||
}
|
||||
|
||||
function _withoutMyAddresses(raw, myAddresses) {
|
||||
return _splitEmailAddresses(raw).filter(addr => !_isMyEmailAddress(addr, myAddresses));
|
||||
}
|
||||
|
||||
function _openCalendarEventFromEmail(uid) {
|
||||
const target = String(uid || '').trim();
|
||||
if (!target) return;
|
||||
@@ -832,13 +849,26 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
? window._myEmailAddresses
|
||||
: (window._myEmailAddress ? [window._myEmailAddress] : []);
|
||||
|
||||
let toAddress = data.from_address;
|
||||
const fromIsMe = _isMyEmailAddress(data.from_address, myAddresses);
|
||||
const originalToWithoutMe = _withoutMyAddresses(data.to, myAddresses);
|
||||
const originalCcWithoutMe = _withoutMyAddresses(data.cc, myAddresses);
|
||||
|
||||
let toAddress = fromIsMe
|
||||
? (originalToWithoutMe.join(', ') || originalCcWithoutMe[0] || data.from_address)
|
||||
: data.from_address;
|
||||
let ccAddresses = '';
|
||||
let subjectPrefix = 'Re: ';
|
||||
|
||||
if (mode === 'reply-all') {
|
||||
// Build reply-all: TO = original sender, CC = everyone else (To + Cc minus me)
|
||||
ccAddresses = buildReplyAllCc(data, myAddresses);
|
||||
if (fromIsMe) {
|
||||
// Replying from Sent should go back to the people I originally wrote
|
||||
// to, not to myself. Keep original Cc recipients on Cc.
|
||||
toAddress = originalToWithoutMe.join(', ') || originalCcWithoutMe[0] || data.from_address;
|
||||
ccAddresses = originalCcWithoutMe.filter(addr => !originalToWithoutMe.some(t => extractEmail(t) === extractEmail(addr))).join(', ');
|
||||
} else {
|
||||
// Build reply-all: TO = original sender, CC = everyone else (To + Cc minus me)
|
||||
ccAddresses = buildReplyAllCc(data, myAddresses);
|
||||
}
|
||||
} else if (mode === 'forward') {
|
||||
toAddress = '';
|
||||
subjectPrefix = 'Fwd: ';
|
||||
@@ -921,7 +951,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
// Agent-provided reply text should land in the email draft the user
|
||||
// already has open. Otherwise mobile users see the source email while the
|
||||
// agent silently creates a second draft elsewhere.
|
||||
const reuseExisting = (mode === 'view' || mode === 'open' || (!!aiSuggestedBody && mode !== 'forward'));
|
||||
const reuseExisting = mode !== 'forward';
|
||||
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
|
||||
? _docModule.findEmailDocId(em.uid, _currentFolder)
|
||||
: null;
|
||||
@@ -930,52 +960,22 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
await _docModule.loadDocument(existingDocId);
|
||||
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
|
||||
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody);
|
||||
_bringEmailReplyDraftToFrontOnMobile();
|
||||
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: true });
|
||||
}
|
||||
_bringEmailReplyDraftToFrontOnMobile();
|
||||
} else {
|
||||
// If the user already has a chat session open, reuse it instead of
|
||||
// spawning a new one. They asked for this explicitly — opening reply
|
||||
// mid-conversation shouldn't whip them out of context.
|
||||
let activeSid = '';
|
||||
try { activeSid = sessionModule?.getCurrentSessionId?.() || ''; } catch {}
|
||||
const activeSid = await _createEmailChat(data);
|
||||
if (!activeSid) {
|
||||
// No chat in flight — keep the old behavior of creating a scoped
|
||||
// email-thread chat, then RE-READ the now-current session id. The
|
||||
// POST below requires a session_id (backend 400s without one), and
|
||||
// the freshly-created chat is what should own the reply draft.
|
||||
await _createEmailChat(data);
|
||||
try { activeSid = sessionModule?.getCurrentSessionId?.() || ''; } catch {}
|
||||
}
|
||||
// Guarantee a session — _createEmailChat can't make one when there's
|
||||
// no enabled default-chat endpoint, which left the reply POSTing a
|
||||
// null session_id → 400. Create a bare session so the draft always
|
||||
// has a home regardless of chat/endpoint config.
|
||||
if (!activeSid) {
|
||||
try {
|
||||
const _fd = new FormData();
|
||||
_fd.append('name', `Email: ${(data.subject || '').slice(0, 60)}`);
|
||||
_fd.append('skip_validation', 'true');
|
||||
const _sres = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' });
|
||||
if (_sres.ok) {
|
||||
const _sdata = await _sres.json();
|
||||
if (_sdata && _sdata.id) {
|
||||
activeSid = _sdata.id;
|
||||
if (sessionModule?.loadSessions) await sessionModule.loadSessions();
|
||||
if (sessionModule?.selectSession) await sessionModule.selectSession(activeSid);
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('reply: bare session create failed', e); }
|
||||
console.error('reply: could not obtain a session_id');
|
||||
import('./ui.js').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const docRes = await fetch(`${API_BASE}/api/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
// Reuse the user's current chat session if there is one (so the
|
||||
// reply draft lives in the chat they were just in); otherwise
|
||||
// null and the new email-chat (created above) takes over.
|
||||
session_id: activeSid || null,
|
||||
session_id: activeSid,
|
||||
title: data.subject,
|
||||
content: content,
|
||||
language: 'email',
|
||||
@@ -1256,31 +1256,58 @@ async function _toggleDone(em, itemEl) {
|
||||
}
|
||||
|
||||
async function _createEmailChat(emailData) {
|
||||
const subject = String(emailData?.subject || 'New Email').trim() || 'New Email';
|
||||
const title = subject === 'New Email' ? 'New Email' : `Email: ${subject.slice(0, 60)}`;
|
||||
try {
|
||||
// Try current session's endpoint first
|
||||
const current = sessionModule.getSessions?.().find(s => s.id === sessionModule.getCurrentSessionId?.());
|
||||
let url, model, endpointId;
|
||||
if (current && current.endpoint_url && current.model) {
|
||||
url = current.endpoint_url;
|
||||
model = current.model;
|
||||
endpointId = current.endpoint_id;
|
||||
} else {
|
||||
// Fall back to default chat config
|
||||
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
|
||||
const dc = await dcRes.json();
|
||||
url = dc.endpoint_url;
|
||||
model = dc.model;
|
||||
endpointId = dc.endpoint_id;
|
||||
const currentSid = sessionModule.getCurrentSessionId?.() || '';
|
||||
const current = sessionModule.getSessions?.().find(s => s.id === currentSid);
|
||||
const currentIsBlank = !!current
|
||||
&& !current.archived
|
||||
&& !current.has_documents
|
||||
&& !current.has_images
|
||||
&& Number(current.message_count || 0) === 0
|
||||
&& current.folder !== 'Assistant'
|
||||
&& current.folder !== 'Tasks';
|
||||
if (currentIsBlank) {
|
||||
const meta = document.getElementById('current-meta');
|
||||
if (meta) meta.textContent = title;
|
||||
return current.id;
|
||||
}
|
||||
let url = current?.endpoint_url || '';
|
||||
let model = current?.model || '';
|
||||
let endpointId = current?.endpoint_id || '';
|
||||
if (!url || !model) {
|
||||
try {
|
||||
const dcRes = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
|
||||
const dc = dcRes.ok ? await dcRes.json() : {};
|
||||
url = dc.endpoint_url || '';
|
||||
model = dc.model || '';
|
||||
endpointId = dc.endpoint_id || '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (url && model) {
|
||||
await sessionModule.createDirectChat(url, model, endpointId);
|
||||
// Set a helpful title in the chat meta
|
||||
const meta = document.getElementById('current-meta');
|
||||
if (meta) meta.textContent = `Email: ${(emailData.subject || '').slice(0, 60)}`;
|
||||
const fd = new FormData();
|
||||
fd.append('name', title);
|
||||
fd.append('skip_validation', 'true');
|
||||
if (url) fd.append('endpoint_url', url);
|
||||
if (model) fd.append('model', model);
|
||||
if (endpointId) fd.append('endpoint_id', endpointId);
|
||||
const res = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
if (!res.ok) {
|
||||
console.error('email chat create failed', res.status, await res.text().catch(() => ''));
|
||||
return '';
|
||||
}
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
const sid = payload?.id || '';
|
||||
if (!sid) return '';
|
||||
if (sessionModule?.loadSessions) await sessionModule.loadSessions();
|
||||
if (sessionModule?.selectSession) await sessionModule.selectSession(sid);
|
||||
const meta = document.getElementById('current-meta');
|
||||
if (meta) meta.textContent = title;
|
||||
return sid;
|
||||
} catch (e) {
|
||||
console.error('Failed to create email chat:', e);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,38 +1319,7 @@ async function _composeNew() {
|
||||
// (doc shows for a frame, then slides up again). Mount once, at injectFreshDoc,
|
||||
// after the session + doc exist.
|
||||
try {
|
||||
// /api/document requires a session_id (returns 400 if null), so reuse
|
||||
// the active chat if there is one — otherwise spin up an email-scoped
|
||||
// chat first, same pattern the reply path uses.
|
||||
let sid = '';
|
||||
try { sid = sessionModule?.getCurrentSessionId?.() || ''; } catch (_) {}
|
||||
if (!sid) {
|
||||
await _createEmailChat({ subject: 'New Email' });
|
||||
try { sid = sessionModule?.getCurrentSessionId?.() || ''; } catch (_) {}
|
||||
}
|
||||
// Guarantee a session — _createEmailChat can't make one when there's no
|
||||
// enabled default-chat endpoint, which left compose POSTing a null
|
||||
// session_id → 400 (the draft silently never appeared). Same bare-session
|
||||
// fallback the reply flow uses.
|
||||
if (!sid) {
|
||||
try {
|
||||
const _fd = new FormData();
|
||||
_fd.append('name', 'New Email');
|
||||
_fd.append('skip_validation', 'true');
|
||||
const _sres = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' });
|
||||
if (_sres.ok) {
|
||||
const _sdata = await _sres.json();
|
||||
if (_sdata && _sdata.id) {
|
||||
sid = _sdata.id;
|
||||
// NOTE: intentionally do NOT loadSessions()/selectSession() here.
|
||||
// Re-selecting the (empty) session re-renders the chat and flashes
|
||||
// the welcome splash for a frame before the draft opens — the
|
||||
// "splash flickers like crazy then email opens" bug. The doc only
|
||||
// needs the session_id; the draft opens in the doc panel regardless.
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('compose: bare session create failed', e); }
|
||||
}
|
||||
const sid = await _createEmailChat({ subject: 'New Email' });
|
||||
if (!sid) {
|
||||
console.error('compose: could not obtain a session_id');
|
||||
import('./ui.js').then(m => m.showError && m.showError('Could not start a new email (no session).')).catch(() => {});
|
||||
|
||||
+282
-45
@@ -35,6 +35,8 @@ let _libSearchSeq = 0;
|
||||
let _libSearchHadResults = false;
|
||||
let _libSearchInFlight = false;
|
||||
let _activeEmailReaderForSelectAll = null;
|
||||
let _libAccountsLoadedAt = 0;
|
||||
const _LIB_ACCOUNTS_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function _isEmailTypingTarget(t) {
|
||||
return !!(t && (
|
||||
@@ -981,6 +983,28 @@ function _loadEmailsFresh() {
|
||||
return _loadEmails({ force: true, useCache: false });
|
||||
}
|
||||
|
||||
function _isChatInteractionBusy() {
|
||||
try {
|
||||
if (window.__odysseusChatBusy) return true;
|
||||
const until = Number(window.__odysseusChatBusyUntil || 0);
|
||||
return until > Date.now();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _loadEmailsWhenChatIdle({ delay = 700, retries = 180, options = {} } = {}) {
|
||||
const run = () => {
|
||||
if (!state._libOpen || !document.getElementById('email-lib-modal')) return;
|
||||
if (_isChatInteractionBusy() && retries > 0) {
|
||||
setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000);
|
||||
return;
|
||||
}
|
||||
_loadEmails(options);
|
||||
};
|
||||
setTimeout(run, Math.max(0, Number(delay) || 0));
|
||||
}
|
||||
|
||||
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
|
||||
if (_libPrewarmTimer || _libPrewarmPromise) return;
|
||||
const elapsed = Date.now() - _libLastPrewarmAt;
|
||||
@@ -1011,7 +1035,10 @@ async function _prewarmEmailViews() {
|
||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json().catch(() => ({}));
|
||||
if (Array.isArray(accountsData.accounts)) state._libAccounts = accountsData.accounts;
|
||||
if (Array.isArray(accountsData.accounts)) {
|
||||
state._libAccounts = accountsData.accounts;
|
||||
_libAccountsLoadedAt = Date.now();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -1737,7 +1764,13 @@ export function openEmailLibrary(opts = {}) {
|
||||
};
|
||||
document.addEventListener('keydown', state._libEscHandler, true);
|
||||
|
||||
_renderAccountsLoading();
|
||||
const grid = document.getElementById('email-lib-grid');
|
||||
if (grid && !grid.children.length) _renderEmailLoading(grid);
|
||||
if (Array.isArray(state._libAccounts) && state._libAccounts.length) {
|
||||
_renderAccountsStrip();
|
||||
} else {
|
||||
_renderAccountsLoading();
|
||||
}
|
||||
// Await accounts before loading emails so the list request carries the
|
||||
// right account_id from the very first fetch (now that we auto-select
|
||||
// an explicit account instead of relying on a 'Default' chip).
|
||||
@@ -1745,17 +1778,31 @@ export function openEmailLibrary(opts = {}) {
|
||||
await _loadAccounts();
|
||||
_loadFolders();
|
||||
_loadEmailReminderBellVisibility();
|
||||
_loadEmails();
|
||||
_loadEmailsWhenChatIdle();
|
||||
})();
|
||||
}
|
||||
|
||||
async function _loadAccounts() {
|
||||
async function _loadAccounts({ force = false } = {}) {
|
||||
const hasCachedAccounts = Array.isArray(state._libAccounts) && state._libAccounts.length;
|
||||
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
|
||||
if (!force && hasCachedAccounts && accountsFresh) {
|
||||
if (!state._libAccountId) {
|
||||
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
|
||||
state._libAccountId = def?.id || null;
|
||||
_publishActiveAccount();
|
||||
}
|
||||
_renderAccountsStrip();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
state._libAccounts = d.accounts || [];
|
||||
} catch (_) { state._libAccounts = []; }
|
||||
_libAccountsLoadedAt = Date.now();
|
||||
} catch (_) {
|
||||
if (!hasCachedAccounts) state._libAccounts = [];
|
||||
}
|
||||
// The 'Default' chip is gone — pick an explicit account so the email
|
||||
// list and any per-email actions (open in new tab, mark read, etc.)
|
||||
// always carry an account_id and can't desync from the server's
|
||||
@@ -2080,6 +2127,60 @@ function _crossFolderCandidates() {
|
||||
return Array.from(new Set(candidates.filter(Boolean)));
|
||||
}
|
||||
|
||||
function _findEmailFolder(patterns, fallback) {
|
||||
const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : [];
|
||||
const lower = new Map(available.map(f => [String(f).toLowerCase(), f]));
|
||||
for (const p of patterns) {
|
||||
const direct = lower.get(String(p).toLowerCase());
|
||||
if (direct) return direct;
|
||||
}
|
||||
return available.find(f => patterns.some(p => String(f).toLowerCase().includes(String(p).toLowerCase()))) || fallback;
|
||||
}
|
||||
|
||||
function _sentFolderName() {
|
||||
return _findEmailFolder(['[Gmail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], 'Sent');
|
||||
}
|
||||
|
||||
function _deriveSearchScope(rawQuery) {
|
||||
const original = String(rawQuery || '').trim();
|
||||
const tokens = original.split(/\s+/).filter(Boolean);
|
||||
let scope = 'all';
|
||||
const kept = [];
|
||||
let forced = '';
|
||||
for (const token of tokens) {
|
||||
const t = token.toLowerCase().replace(/^#+/, '').replace(/:$/, '');
|
||||
if (['sent', 'sentmail', 'sent-mail', 'outbox'].includes(t)) {
|
||||
forced = 'sent';
|
||||
continue;
|
||||
}
|
||||
if (['inbox'].includes(t)) {
|
||||
forced = 'inbox';
|
||||
continue;
|
||||
}
|
||||
kept.push(token);
|
||||
}
|
||||
if (forced) scope = forced;
|
||||
let folder = 'INBOX';
|
||||
let serverScope = 'all';
|
||||
if (scope === 'sent') {
|
||||
folder = _sentFolderName();
|
||||
serverScope = 'folder';
|
||||
} else if (scope === 'inbox') {
|
||||
folder = 'INBOX';
|
||||
serverScope = 'folder';
|
||||
} else if (scope === 'current') {
|
||||
folder = state._libFolder || 'INBOX';
|
||||
serverScope = 'folder';
|
||||
}
|
||||
return {
|
||||
scope,
|
||||
folder,
|
||||
serverScope,
|
||||
q: forced ? kept.join(' ').trim() : original,
|
||||
forced,
|
||||
};
|
||||
}
|
||||
|
||||
// Snapshot of state._libEmails taken right before search starts so we
|
||||
// can both filter locally and restore on clear without re-fetching.
|
||||
let _libPreSearchEmails = null;
|
||||
@@ -2429,6 +2530,15 @@ function _addSearchPill(pill) {
|
||||
_applyPillFilter();
|
||||
}
|
||||
|
||||
function _searchQueryFromPills() {
|
||||
const parts = [];
|
||||
for (const p of state._libSearchPills || []) {
|
||||
if (p.type === 'text' && p.text) parts.push(String(p.text).trim());
|
||||
else if (p.type === 'contact' && (p.email || p.name)) parts.push(String(p.email || p.name).trim());
|
||||
}
|
||||
return parts.filter(Boolean).join(' ').trim();
|
||||
}
|
||||
|
||||
function _removeSearchPillAt(idx) {
|
||||
if (!Array.isArray(state._libSearchPills)) return;
|
||||
const removed = state._libSearchPills[idx];
|
||||
@@ -2455,6 +2565,26 @@ function _removeSearchPillAt(idx) {
|
||||
_loadEmails({ useCache: true });
|
||||
return;
|
||||
}
|
||||
const remainingQuery = _searchQueryFromPills();
|
||||
if (remainingQuery.length >= 2) {
|
||||
state._libSearch = remainingQuery;
|
||||
const _searchInput = document.getElementById('email-lib-search');
|
||||
if (_searchInput) _searchInput.value = '';
|
||||
state._libSearchDraft = '';
|
||||
_doSearch();
|
||||
return;
|
||||
}
|
||||
if ((state._libSearchPills || []).length && _libSearchHadResults) {
|
||||
_libSearchHadResults = false;
|
||||
_libPreSearchEmails = null;
|
||||
_libPreSearchTotal = 0;
|
||||
_libServerSearchEmails = null;
|
||||
_libServerSearchTotal = 0;
|
||||
state._libSearch = '';
|
||||
state._libOffset = 0;
|
||||
_loadEmails({ useCache: true });
|
||||
return;
|
||||
}
|
||||
_applyPillFilter();
|
||||
}
|
||||
|
||||
@@ -2724,8 +2854,9 @@ window.addEventListener('click', (e) => {
|
||||
async function _doSearch() {
|
||||
_exitEmailReaderModeForList();
|
||||
const seq = ++_libSearchSeq;
|
||||
const q = state._libSearch.trim();
|
||||
if (q.length < 2) {
|
||||
const derived = _deriveSearchScope(state._libSearch);
|
||||
const q = derived.q;
|
||||
if (q.length < 2 && !derived.forced) {
|
||||
// Empty or too short — restore the normal folder if a previous search
|
||||
// had replaced the grid contents.
|
||||
if (_libSearchHadResults) {
|
||||
@@ -2738,7 +2869,8 @@ async function _doSearch() {
|
||||
return;
|
||||
}
|
||||
const accountAtStart = state._libAccountId || '';
|
||||
const folderAtStart = state._libFolder || 'INBOX';
|
||||
const folderAtStart = derived.folder || state._libFolder || 'INBOX';
|
||||
const serverScopeAtStart = derived.serverScope || 'all';
|
||||
// No grid-blanking spinner — the local filter already painted something
|
||||
// useful. Surface progress in the stats badge instead so the user knows
|
||||
// the server search is still grinding.
|
||||
@@ -2753,25 +2885,67 @@ async function _doSearch() {
|
||||
|
||||
const stillCurrent = () => (
|
||||
seq === _libSearchSeq &&
|
||||
q === state._libSearch.trim() &&
|
||||
q === _deriveSearchScope(state._libSearch).q &&
|
||||
accountAtStart === (state._libAccountId || '') &&
|
||||
folderAtStart === (state._libFolder || 'INBOX')
|
||||
folderAtStart === (_deriveSearchScope(state._libSearch).folder || state._libFolder || 'INBOX')
|
||||
);
|
||||
const searchUrl = (localOnly = false) => {
|
||||
const params = new URLSearchParams({
|
||||
folder: folderAtStart,
|
||||
q,
|
||||
limit: '100',
|
||||
scope: serverScopeAtStart,
|
||||
});
|
||||
if (accountAtStart) params.set('account_id', accountAtStart);
|
||||
if (localOnly) params.set('local_only', '1');
|
||||
return `${API_BASE}/api/email/search?${params.toString()}`;
|
||||
};
|
||||
const folderListUrl = () => {
|
||||
const params = new URLSearchParams({
|
||||
folder: folderAtStart,
|
||||
limit: '100',
|
||||
offset: '0',
|
||||
filter: state._libFilter || 'all',
|
||||
});
|
||||
if (accountAtStart) params.set('account_id', accountAtStart);
|
||||
return `${API_BASE}/api/email/list?${params.toString()}`;
|
||||
};
|
||||
const mergeSearchResults = (painted, incoming) => {
|
||||
const byKey = new Map();
|
||||
const out = [];
|
||||
const add = (em) => {
|
||||
if (!em) return;
|
||||
const key = `${em.account_id || accountAtStart || ''}:${em.folder || folderAtStart || ''}:${em.uid || em.message_id || JSON.stringify(em)}`;
|
||||
if (byKey.has(key)) return;
|
||||
byKey.set(key, em);
|
||||
out.push(em);
|
||||
};
|
||||
(painted || []).forEach(add);
|
||||
const additions = [];
|
||||
const addIncoming = (em) => {
|
||||
if (!em) return;
|
||||
const key = `${em.account_id || accountAtStart || ''}:${em.folder || folderAtStart || ''}:${em.uid || em.message_id || JSON.stringify(em)}`;
|
||||
if (byKey.has(key)) return;
|
||||
byKey.set(key, em);
|
||||
additions.push(em);
|
||||
};
|
||||
(incoming || []).forEach(addIncoming);
|
||||
additions.sort((a, b) => {
|
||||
const ad = Number(a?.date_epoch || 0);
|
||||
const bd = Number(b?.date_epoch || 0);
|
||||
if (bd !== ad) return bd - ad;
|
||||
return String(b?.date || '').localeCompare(String(a?.date || ''));
|
||||
});
|
||||
return out.concat(additions);
|
||||
};
|
||||
let paintedInterimResults = false;
|
||||
const paintSearchData = (data, interim = false) => {
|
||||
if (!stillCurrent()) return false;
|
||||
if (data.error) throw new Error(data.error);
|
||||
const results = data.emails || [];
|
||||
let results = data.emails || [];
|
||||
if (!interim && paintedInterimResults) {
|
||||
results = mergeSearchResults(state._libEmails || [], results);
|
||||
}
|
||||
if (!interim && paintedInterimResults && results.length === 0) {
|
||||
if (stats) {
|
||||
const count = state._libTotal || (state._libEmails || []).length;
|
||||
@@ -2789,7 +2963,7 @@ async function _doSearch() {
|
||||
const preservingBase = !!(_libServerSearchEmails && pills.length > 1);
|
||||
if (!preservingBase) {
|
||||
_libServerSearchEmails = results.slice();
|
||||
_libServerSearchTotal = data.total || results.length;
|
||||
_libServerSearchTotal = Math.max(Number(data.total || 0), results.length);
|
||||
_libPreSearchEmails = results.slice();
|
||||
_libPreSearchTotal = _libServerSearchTotal;
|
||||
state._libEmails = results;
|
||||
@@ -2803,7 +2977,7 @@ async function _doSearch() {
|
||||
if (!(state._libEmails || []).length && !preservingBase) state._libEmails = results;
|
||||
}
|
||||
_renderGrid();
|
||||
const count = data.total || results.length;
|
||||
const count = Math.max(Number(data.total || 0), results.length);
|
||||
if (stats) {
|
||||
if (interim) {
|
||||
stats.textContent = `${count} cached match${count === 1 ? '' : 'es'} · searching…`;
|
||||
@@ -2822,9 +2996,22 @@ async function _doSearch() {
|
||||
};
|
||||
|
||||
try {
|
||||
if (q.length < 2 && derived.forced) {
|
||||
const res = await fetch(folderListUrl());
|
||||
const data = await res.json();
|
||||
if (!stillCurrent()) return;
|
||||
paintSearchData({
|
||||
emails: (data.emails || []).map(em => ({ ...em, folder: folderAtStart })),
|
||||
total: data.total || (data.emails || []).length,
|
||||
source: 'folder',
|
||||
sync: { source: 'folder' },
|
||||
}, false);
|
||||
return;
|
||||
}
|
||||
const fullSearchPromise = fetch(searchUrl(false)).then(res => res.json());
|
||||
const localSearchPromise = fetch(searchUrl(true)).then(res => res.json());
|
||||
try {
|
||||
const localRes = await fetch(searchUrl(true));
|
||||
const localData = await localRes.json();
|
||||
const localData = await localSearchPromise;
|
||||
if (!stillCurrent()) return;
|
||||
if (!localData.error && (localData.emails || []).length) {
|
||||
paintSearchData(localData, true);
|
||||
@@ -2833,8 +3020,7 @@ async function _doSearch() {
|
||||
if (!stillCurrent()) return;
|
||||
}
|
||||
|
||||
const res = await fetch(searchUrl(false));
|
||||
const data = await res.json();
|
||||
const data = await fullSearchPromise;
|
||||
if (!stillCurrent()) return;
|
||||
paintSearchData(data, false);
|
||||
} catch (e) {
|
||||
@@ -3401,9 +3587,12 @@ function _createCard(em) {
|
||||
card.appendChild(cb);
|
||||
}
|
||||
|
||||
// In Sent folder, show the recipient(s) — the sender is always you and
|
||||
// hides the actually useful info. Outside Sent, show the sender as before.
|
||||
const isSentFolderEarly = /sent/i.test(state._libFolder);
|
||||
// In Sent results, show the recipient(s) — the sender is always you and
|
||||
// hides the actually useful info. Search results can be stamped with their
|
||||
// real folder while the visible folder selector still says INBOX, so use the
|
||||
// email's folder first.
|
||||
const cardFolder = em.folder || state._libFolder || 'INBOX';
|
||||
const isSentFolderEarly = /sent/i.test(cardFolder);
|
||||
let senderName;
|
||||
let senderAddress;
|
||||
if (isSentFolderEarly) {
|
||||
@@ -3482,7 +3671,7 @@ function _createCard(em) {
|
||||
}
|
||||
|
||||
// Done check + unread dot stay next to the subject on the left.
|
||||
const isSentFolder = /sent/i.test(state._libFolder);
|
||||
const isSentFolder = /sent/i.test(cardFolder);
|
||||
if (!isSentFolder) {
|
||||
const doneCheck = document.createElement('span');
|
||||
doneCheck.className = 'email-card-done' + (em.is_answered ? ' active' : '');
|
||||
@@ -3512,10 +3701,10 @@ function _createCard(em) {
|
||||
}
|
||||
try {
|
||||
if (newState) {
|
||||
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else {
|
||||
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
|
||||
}
|
||||
} catch (err) { console.error(err); }
|
||||
};
|
||||
@@ -3571,8 +3760,14 @@ function _createCard(em) {
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'memory-item-meta';
|
||||
meta.style.cssText = 'font-size:10px;opacity:0.7;margin-top:2px;';
|
||||
const showFolderChip = !!(_libSearchHadResults && cardFolder);
|
||||
const prettyFolder = folderDisplayName(cardFolder);
|
||||
const sentChip = isSentFolderEarly ? '<span class="email-sent-chip" title="Sent email">Sent</span>' : '';
|
||||
const folderChip = showFolderChip && !isSentFolderEarly
|
||||
? `<span class="email-folder-chip" title="${_esc(cardFolder)}">${_esc(prettyFolder)}</span>`
|
||||
: '';
|
||||
const senderPrefix = isSentFolderEarly ? 'to ' : '';
|
||||
meta.innerHTML = `<span class="email-meta-sender" data-email="${_esc(senderAddress || '')}" data-name="${_esc(senderName || '')}"><span style="opacity:0.55">${senderPrefix}</span><span style="color:${color};font-weight:600">${_esc(senderName)}</span></span><span class="email-meta-sep"> · </span><span class="email-meta-date">${_esc(dateStr)}</span>`;
|
||||
meta.innerHTML = `${sentChip}${folderChip}<span class="email-meta-sender" data-email="${_esc(senderAddress || '')}" data-name="${_esc(senderName || '')}"><span style="opacity:0.55">${senderPrefix}</span><span style="color:${color};font-weight:600">${_esc(senderName)}</span></span><span class="email-meta-sep"> · </span><span class="email-meta-date">${_esc(dateStr)}</span>`;
|
||||
content.appendChild(meta);
|
||||
|
||||
card.appendChild(content);
|
||||
@@ -5284,6 +5479,63 @@ function _wireAttachmentHandlers(reader, folder) {
|
||||
// a ReferenceError when this fn is called from contexts that don't have
|
||||
// _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow).
|
||||
const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
reader.querySelectorAll('.email-attachments-download-all').forEach(btn => {
|
||||
if (btn.dataset.wired === '1') return;
|
||||
btn.dataset.wired = '1';
|
||||
btn.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (btn.dataset.downloading === '1') return;
|
||||
const uid = btn.dataset.attUid;
|
||||
const sourceFolder = btn.dataset.attFolder || useFolder;
|
||||
const count = Number(btn.dataset.attCount || 0);
|
||||
if (!uid) return;
|
||||
const originalHtml = btn.innerHTML;
|
||||
const originalTitle = btn.title;
|
||||
btn.dataset.downloading = '1';
|
||||
btn.classList.add('is-loading');
|
||||
try {
|
||||
const sp = window.spinnerModule || (await import('./spinner.js')).default;
|
||||
const wp = sp.createWhirlpool(12);
|
||||
wp.element.style.margin = '0';
|
||||
btn.textContent = '';
|
||||
btn.appendChild(wp.element);
|
||||
const label = document.createElement('span');
|
||||
label.textContent = 'All';
|
||||
btn.appendChild(label);
|
||||
} catch (_) {
|
||||
btn.textContent = 'All...';
|
||||
}
|
||||
try {
|
||||
const url = `${API_BASE}/api/email/attachments-download/${encodeURIComponent(uid)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => '');
|
||||
console.error('attachments zip download failed', res.status, msg);
|
||||
location.href = url;
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `email-${uid}-attachments.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||
try { uiModule.showToast && uiModule.showToast(`Downloading ${count || 'all'} attachments`); } catch (_) {}
|
||||
} catch (e) {
|
||||
console.error('attachments zip download error', e);
|
||||
try { const { showError } = await import('./ui.js'); showError('Could not download attachments'); } catch (_) {}
|
||||
} finally {
|
||||
delete btn.dataset.downloading;
|
||||
btn.classList.remove('is-loading');
|
||||
btn.title = originalTitle;
|
||||
btn.innerHTML = originalHtml;
|
||||
}
|
||||
});
|
||||
});
|
||||
reader.querySelectorAll('.email-attachment-open').forEach(openBtn => {
|
||||
if (openBtn.dataset.wired === '1') return;
|
||||
openBtn.dataset.wired = '1';
|
||||
@@ -5487,11 +5739,15 @@ function _buildAttsHtmlFor(uid, data) {
|
||||
? `Thread attachments (${related.length})`
|
||||
: `Hidden inline attachments (${hidden.length})`;
|
||||
const startCollapsed = !visible.length && !related.length;
|
||||
const downloadAllBtn = visible.length > 4
|
||||
? `<button type="button" class="email-attachments-download-all" title="Download all attachments" data-att-uid="${_esc(uid)}" data-att-folder="${_esc(data.folder || state._libFolder || 'INBOX')}" data-att-count="${visible.length}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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><span>All</span></button>`
|
||||
: '';
|
||||
return (
|
||||
`<div class="email-reader-atts-wrap${startCollapsed ? ' collapsed' : ''}">`
|
||||
+ '<div class="email-reader-atts-header email-summary-toggle" role="button" tabindex="0">'
|
||||
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'
|
||||
+ `<span>${label}</span>`
|
||||
+ downloadAllBtn
|
||||
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
|
||||
+ '</div>'
|
||||
+ visibleSection
|
||||
@@ -6350,26 +6606,7 @@ async function _translateEmail(reader, language, opts = {}) {
|
||||
}
|
||||
|
||||
async function _maybeAutoTranslateEmail(reader) {
|
||||
if (!reader || reader.dataset.autoTranslateChecked === '1') return;
|
||||
reader.dataset.autoTranslateChecked = '1';
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/config`);
|
||||
const cfg = await res.json();
|
||||
if (!cfg || !cfg.email_auto_translate) return;
|
||||
try {
|
||||
const sid = window.sessionModule?.getCurrentSessionId?.() || '';
|
||||
if (sid) {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 800);
|
||||
const statusRes = await fetch(`${API_BASE}/api/chat/stream_status/${encodeURIComponent(sid)}`, {
|
||||
signal: ctrl.signal,
|
||||
}).catch(() => null);
|
||||
clearTimeout(timer);
|
||||
if (statusRes && statusRes.ok) return;
|
||||
}
|
||||
} catch (_) {}
|
||||
await _translateEmail(reader, cfg.email_translate_language || 'English', { auto: true });
|
||||
} catch (_) {}
|
||||
if (reader) reader.dataset.autoTranslateChecked = '1';
|
||||
}
|
||||
|
||||
// Keep an email ⋮ dropdown inside the viewport: when it would spill past the
|
||||
|
||||
+146
-2
@@ -24,6 +24,131 @@ const MAX_FILES = 10;
|
||||
const MAX_VISIBLE = 3;
|
||||
let _expanded = false;
|
||||
|
||||
function _isMobileViewport() {
|
||||
return window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
|
||||
}
|
||||
|
||||
function _isCroppableImage(f) {
|
||||
const mime = (f?.type || '').toLowerCase();
|
||||
const name = (f?.name || '').toLowerCase();
|
||||
if (!(mime.startsWith('image/') || /\.(png|jpe?g|webp|bmp)$/i.test(name))) return false;
|
||||
return !mime.includes('svg') && !mime.includes('gif') && !/\.svg|\.gif$/i.test(name);
|
||||
}
|
||||
|
||||
function _loadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function _canvasToBlob(canvas, type, quality) {
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, type || 'image/png', quality));
|
||||
}
|
||||
|
||||
async function _openMobileCropper(file) {
|
||||
const url = _getPreviewUrl(file);
|
||||
const imgProbe = await _loadImage(url);
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'attach-crop-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="attach-crop-panel" role="dialog" aria-modal="true" aria-label="Crop image">
|
||||
<div class="attach-crop-stage">
|
||||
<img class="attach-crop-img" alt="">
|
||||
<div class="attach-crop-box"><span class="attach-crop-handle"></span></div>
|
||||
</div>
|
||||
<div class="attach-crop-actions">
|
||||
<button type="button" class="attach-crop-btn" data-action="cancel">Cancel</button>
|
||||
<button type="button" class="attach-crop-btn" data-action="original">Original</button>
|
||||
<button type="button" class="attach-crop-btn attach-crop-primary" data-action="crop">Use crop</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
const img = overlay.querySelector('.attach-crop-img');
|
||||
const box = overlay.querySelector('.attach-crop-box');
|
||||
img.src = url;
|
||||
img.alt = file.name || 'image';
|
||||
|
||||
let crop = { x: 0.08, y: 0.08, w: 0.84, h: 0.84 };
|
||||
let drag = null;
|
||||
|
||||
function applyCrop() {
|
||||
const r = img.getBoundingClientRect();
|
||||
const pr = overlay.querySelector('.attach-crop-stage').getBoundingClientRect();
|
||||
box.style.left = (r.left - pr.left + crop.x * r.width) + 'px';
|
||||
box.style.top = (r.top - pr.top + crop.y * r.height) + 'px';
|
||||
box.style.width = (crop.w * r.width) + 'px';
|
||||
box.style.height = (crop.h * r.height) + 'px';
|
||||
}
|
||||
function clampCrop() {
|
||||
crop.w = Math.max(0.12, Math.min(1, crop.w));
|
||||
crop.h = Math.max(0.12, Math.min(1, crop.h));
|
||||
crop.x = Math.max(0, Math.min(1 - crop.w, crop.x));
|
||||
crop.y = Math.max(0, Math.min(1 - crop.h, crop.y));
|
||||
}
|
||||
function finish(value) {
|
||||
overlay.remove();
|
||||
window.removeEventListener('resize', applyCrop);
|
||||
resolve(value);
|
||||
}
|
||||
requestAnimationFrame(applyCrop);
|
||||
img.addEventListener('load', applyCrop);
|
||||
window.addEventListener('resize', applyCrop);
|
||||
|
||||
box.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
box.setPointerCapture(e.pointerId);
|
||||
drag = {
|
||||
mode: e.target.classList.contains('attach-crop-handle') ? 'resize' : 'move',
|
||||
sx: e.clientX,
|
||||
sy: e.clientY,
|
||||
start: { ...crop },
|
||||
};
|
||||
});
|
||||
box.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const r = img.getBoundingClientRect();
|
||||
const dx = (e.clientX - drag.sx) / Math.max(1, r.width);
|
||||
const dy = (e.clientY - drag.sy) / Math.max(1, r.height);
|
||||
if (drag.mode === 'resize') {
|
||||
crop.w = drag.start.w + dx;
|
||||
crop.h = drag.start.h + dy;
|
||||
} else {
|
||||
crop.x = drag.start.x + dx;
|
||||
crop.y = drag.start.y + dy;
|
||||
}
|
||||
clampCrop();
|
||||
applyCrop();
|
||||
});
|
||||
box.addEventListener('pointerup', () => { drag = null; });
|
||||
box.addEventListener('pointercancel', () => { drag = null; });
|
||||
|
||||
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => finish(null));
|
||||
overlay.querySelector('[data-action="original"]').addEventListener('click', () => finish(file));
|
||||
overlay.querySelector('[data-action="crop"]').addEventListener('click', async () => {
|
||||
clampCrop();
|
||||
const canvas = document.createElement('canvas');
|
||||
const sx = Math.round(crop.x * imgProbe.naturalWidth);
|
||||
const sy = Math.round(crop.y * imgProbe.naturalHeight);
|
||||
const sw = Math.max(1, Math.round(crop.w * imgProbe.naturalWidth));
|
||||
const sh = Math.max(1, Math.round(crop.h * imgProbe.naturalHeight));
|
||||
canvas.width = sw;
|
||||
canvas.height = sh;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(imgProbe, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||
const type = file.type && file.type !== 'image/bmp' ? file.type : 'image/png';
|
||||
const blob = await _canvasToBlob(canvas, type, 0.92);
|
||||
if (!blob) { finish(file); return; }
|
||||
const ext = type.includes('jpeg') ? 'jpg' : (type.split('/')[1] || 'png');
|
||||
const base = (file.name || 'image').replace(/\.[^.]+$/, '');
|
||||
finish(new File([blob], `${base}-cropped.${ext}`, { type, lastModified: Date.now() }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _getPreviewUrl(f) {
|
||||
if (!f) return '';
|
||||
let url = _previewUrls.get(f);
|
||||
@@ -231,17 +356,35 @@ export async function uploadPending(opts = {}) {
|
||||
/**
|
||||
* Add files to pending list (capped at MAX_FILES)
|
||||
*/
|
||||
export function addFiles(files) {
|
||||
export async function addFiles(files) {
|
||||
for (const f of files) {
|
||||
if (pendingFiles.length >= MAX_FILES) {
|
||||
_showToast(`Max ${MAX_FILES} files allowed`);
|
||||
break;
|
||||
}
|
||||
pendingFiles.push(f);
|
||||
let nextFile = f;
|
||||
if (_isMobileViewport() && _isCroppableImage(f)) {
|
||||
try {
|
||||
nextFile = await _openMobileCropper(f);
|
||||
} catch (_) {
|
||||
nextFile = f;
|
||||
}
|
||||
if (!nextFile) continue;
|
||||
}
|
||||
pendingFiles.push(nextFile);
|
||||
}
|
||||
renderAttachStrip();
|
||||
}
|
||||
|
||||
export async function cropForMobileUpload(file) {
|
||||
if (!_isMobileViewport() || !_isCroppableImage(file)) return file;
|
||||
try {
|
||||
return await _openMobileCropper(file);
|
||||
} catch (_) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
function _showToast(msg) {
|
||||
if (window.showToast) { window.showToast(msg); return; }
|
||||
// Fallback inline toast
|
||||
@@ -326,6 +469,7 @@ const fileHandlerModule = {
|
||||
removePending,
|
||||
uploadPending,
|
||||
addFiles,
|
||||
cropForMobileUpload,
|
||||
getPendingCount,
|
||||
getPendingInfo,
|
||||
getPendingRaw,
|
||||
|
||||
+72
-3
@@ -42,7 +42,7 @@ let _activeModel = null;
|
||||
let _activeAlbum = null;
|
||||
let _galleryCascaded = false; // play the domino-in cascade once per open
|
||||
let _favoritesOnly = false;
|
||||
let _sort = 'shuffle';
|
||||
let _sort = 'recent';
|
||||
let _shuffleSeed = Math.floor(Math.random() * 2 ** 31);
|
||||
let _offset = 0;
|
||||
// Page size — computed from the grid's visible area so taller / wider
|
||||
@@ -1152,8 +1152,14 @@ function _wireUploadTile() {
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*,video/*';
|
||||
input.multiple = true;
|
||||
input.addEventListener('change', () => {
|
||||
if (input.files.length) _bulkUpload([...input.files], _activeAlbum);
|
||||
input.addEventListener('change', async () => {
|
||||
if (!input.files.length) return;
|
||||
const files = [];
|
||||
for (const file of [...input.files]) {
|
||||
const nextFile = await fileHandlerModule.cropForMobileUpload(file);
|
||||
if (nextFile) files.push(nextFile);
|
||||
}
|
||||
if (files.length) _bulkUpload(files, _activeAlbum);
|
||||
});
|
||||
input.click();
|
||||
});
|
||||
@@ -2810,6 +2816,68 @@ export function openGallery() {
|
||||
searchInput.focus();
|
||||
}
|
||||
|
||||
function _showImagesTab() {
|
||||
const modal = document.getElementById('gallery-modal');
|
||||
if (!modal) return;
|
||||
modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active'));
|
||||
modal.querySelector('.gallery-tab[data-tab="images"]')?.classList.add('active');
|
||||
const imagesContainer = document.getElementById('gallery-images-container');
|
||||
const albumsContainer = document.getElementById('gallery-albums-container');
|
||||
const editorContainer = document.getElementById('gallery-editor-container');
|
||||
const settingsContainer = document.getElementById('gallery-settings-container');
|
||||
if (imagesContainer) imagesContainer.style.display = '';
|
||||
if (albumsContainer) albumsContainer.style.display = 'none';
|
||||
if (editorContainer) editorContainer.style.display = 'none';
|
||||
if (settingsContainer) settingsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
export async function openGalleryImage(imageId) {
|
||||
if (!imageId) {
|
||||
openGallery();
|
||||
return;
|
||||
}
|
||||
openGallery();
|
||||
_showImagesTab();
|
||||
_search = '';
|
||||
_activeTags = [];
|
||||
_activeModel = null;
|
||||
_activeAlbum = null;
|
||||
_favoritesOnly = false;
|
||||
_sort = 'recent';
|
||||
const searchInput = document.getElementById('gallery-search');
|
||||
if (searchInput) searchInput.value = '';
|
||||
const sortSel = document.getElementById('gallery-sort');
|
||||
if (sortSel) sortSel.value = 'recent';
|
||||
const detail = document.getElementById('gallery-detail');
|
||||
if (detail) detail.style.display = 'none';
|
||||
|
||||
try {
|
||||
await _fetchLibrary(false);
|
||||
let img = _items.find(i => String(i.id) === String(imageId));
|
||||
if (!img) {
|
||||
const res = await fetch(`${API_BASE}/api/gallery/${encodeURIComponent(imageId)}`, { credentials: 'same-origin' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
img = data.image || data;
|
||||
}
|
||||
}
|
||||
if (!img || !img.id) {
|
||||
uiModule.showToast?.('Photo not found in gallery', 3000);
|
||||
return;
|
||||
}
|
||||
_openDetail(img);
|
||||
const card = document.querySelector(`.gallery-card[data-id="${CSS.escape(String(imageId))}"]`);
|
||||
if (card) {
|
||||
card.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
card.classList.add('gallery-card-focus');
|
||||
setTimeout(() => card.classList.remove('gallery-card-focus'), 1600);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[gallery] open image failed', err);
|
||||
uiModule.showToast?.('Could not open photo in gallery', 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function _doCloseGallery() {
|
||||
const editorMounted = !!document.querySelector('#gallery-editor-container .gallery-editor');
|
||||
if ((window.__galleryEditLive || isEditorOpen() || editorMounted) && !window.__galleryAllowCloseEditor) {
|
||||
@@ -2882,6 +2950,7 @@ function _humanSize(bytes) {
|
||||
|
||||
const galleryModule = {
|
||||
openGallery,
|
||||
openGalleryImage,
|
||||
closeGallery,
|
||||
isGalleryOpen,
|
||||
};
|
||||
|
||||
@@ -265,6 +265,134 @@ const _applyImageTool = createApplyImageTool({
|
||||
uiModule,
|
||||
});
|
||||
|
||||
function _setAiCommandStatus(text, kind = '') {
|
||||
const el = document.getElementById('ge-ai-command-status');
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.dataset.kind = kind || '';
|
||||
}
|
||||
|
||||
function _clickToolButton(toolId) {
|
||||
const btn = state.container?.querySelector(`.ge-tool-btn[data-tool="${toolId}"]`);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
|
||||
function _runExistingButton(id, status) {
|
||||
const btn = document.getElementById(id);
|
||||
if (!btn) {
|
||||
_setAiCommandStatus('That edit is not available in this editor state.', 'error');
|
||||
return false;
|
||||
}
|
||||
if (status) _setAiCommandStatus(status, 'running');
|
||||
btn.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
function _buildAiCommandBox() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ge-ai-command ge-ai-command-collapsed';
|
||||
wrap.id = 'ge-ai-command';
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="ge-ai-command-toggle" id="ge-ai-command-toggle" aria-expanded="false">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
<span>AI Edit</span>
|
||||
</button>
|
||||
<form class="ge-ai-command-form" id="ge-ai-command-form">
|
||||
<input type="text" class="ge-ai-command-input" id="ge-ai-command-input" autocomplete="off" />
|
||||
<button type="submit" class="ge-ai-command-run" id="ge-ai-command-run" title="Run AI edit" aria-label="Run AI edit">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="12" y1="19" x2="12" y2="5"></line>
|
||||
<polyline points="5 12 12 5 19 12"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="ge-ai-command-close" id="ge-ai-command-close" title="Close AI edit" aria-label="Close AI edit">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<div class="ge-ai-command-status" id="ge-ai-command-status" aria-live="polite"></div>
|
||||
`;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function _wireAiCommandBox() {
|
||||
const wrap = document.getElementById('ge-ai-command');
|
||||
const toggle = document.getElementById('ge-ai-command-toggle');
|
||||
const closeBtn = document.getElementById('ge-ai-command-close');
|
||||
const form = document.getElementById('ge-ai-command-form');
|
||||
const input = document.getElementById('ge-ai-command-input');
|
||||
const runBtn = document.getElementById('ge-ai-command-run');
|
||||
if (!wrap || !form || !input || !runBtn) return;
|
||||
wrap.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
wrap.addEventListener('click', (e) => e.stopPropagation());
|
||||
const setOpen = (open) => {
|
||||
wrap.classList.toggle('ge-ai-command-collapsed', !open);
|
||||
toggle?.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
if (open) requestAnimationFrame(() => input.focus());
|
||||
};
|
||||
toggle?.addEventListener('click', () => setOpen(wrap.classList.contains('ge-ai-command-collapsed')));
|
||||
closeBtn?.addEventListener('click', () => setOpen(false));
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const prompt = input.value.trim();
|
||||
if (!prompt) {
|
||||
_setAiCommandStatus('Type what you want changed.', 'error');
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
const p = prompt.toLowerCase();
|
||||
try {
|
||||
if (/\b(remove|erase|cut\s*out|transparent)\b.*\b(bg|background)\b|\b(bg|background)\b.*\b(remove|erase|transparent)\b/.test(p)) {
|
||||
_clickToolButton('rembg');
|
||||
_runExistingButton('ge-rembg-run', 'Removing background...');
|
||||
return;
|
||||
}
|
||||
if (/\b(upscale|higher\s*res|increase\s*resolution|bigger|2x|4x)\b/.test(p)) {
|
||||
_clickToolButton('upscale');
|
||||
_runExistingButton('ge-upscale-ai', 'Upscaling image...');
|
||||
return;
|
||||
}
|
||||
if (/\b(denoise|noise|grain|grainy|clean\s*up)\b/.test(p)) {
|
||||
_setAiCommandStatus('Denoising image...', 'running');
|
||||
await _applyImageTool('/api/image/denoise', { strength: 0.55 }, 'Denoised', runBtn, { busyLabel: 'Denoising...' });
|
||||
_setAiCommandStatus('Added denoised layer.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\b(face|portrait|skin|selfie|restore)\b/.test(p)) {
|
||||
_setAiCommandStatus('Enhancing face/portrait...', 'running');
|
||||
await _applyImageTool('/api/image/enhance-face', {}, 'Enhanced Face', runBtn, { busyLabel: 'Enhancing...' });
|
||||
_setAiCommandStatus('Added enhanced layer.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\b(sharpen|sharp|crisp|clearer|make it look better|enhance|improve|better)\b/.test(p)) {
|
||||
const amount = document.getElementById('ge-sharpen-amount');
|
||||
if (amount) {
|
||||
amount.value = '65';
|
||||
amount.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
_clickToolButton('sharpen');
|
||||
_runExistingButton('ge-sharpen-run', 'Sharpening image...');
|
||||
return;
|
||||
}
|
||||
|
||||
const stylePrompt = document.getElementById('ge-style-prompt');
|
||||
const styleStrength = document.getElementById('ge-style-strength');
|
||||
if (stylePrompt) stylePrompt.value = prompt;
|
||||
if (styleStrength) {
|
||||
styleStrength.value = /\b(subtle|slight|small)\b/.test(p) ? '35' : '55';
|
||||
styleStrength.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
_clickToolButton('style');
|
||||
_runExistingButton('ge-style-run', 'Running full-image AI edit...');
|
||||
} catch (err) {
|
||||
console.error('[ge-ai-command] failed', err);
|
||||
_setAiCommandStatus(err?.message || 'AI edit failed', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Layer offsets for move tool
|
||||
|
||||
// ── Layer class ──
|
||||
@@ -2551,6 +2679,7 @@ function _buildEditor(container) {
|
||||
state.transformOverlay.className = 'ge-transform-overlay';
|
||||
state.transformOverlayCtx = state.transformOverlay.getContext('2d');
|
||||
canvasArea.appendChild(state.transformOverlay);
|
||||
canvasArea.appendChild(_buildAiCommandBox());
|
||||
// Keep the transform handles glued to the photo while the canvas-area
|
||||
// scrolls (the overlay is anchored to the canvas's live rect, so a
|
||||
// re-draw on scroll re-reads its position).
|
||||
@@ -2879,6 +3008,7 @@ function _buildEditor(container) {
|
||||
applyImageTool: _applyImageTool,
|
||||
uiModule,
|
||||
});
|
||||
_wireAiCommandBox();
|
||||
|
||||
// Merge / Flatten buttons (layer-panel footer) — full
|
||||
// implementation in editor/wire-merge-buttons.js.
|
||||
|
||||
+4
-1
@@ -6,7 +6,10 @@ import Storage from './storage.js';
|
||||
function clearFreshComposerRestore() {
|
||||
const msgInput = document.getElementById('message');
|
||||
if (!msgInput) return;
|
||||
const hasSessionTarget = !!(window.location.hash || Storage.get('lastSessionId'));
|
||||
const hash = window.location.hash || '';
|
||||
const isEntityHash = /^#(?:document|note|image|email|event|task|skill|research)-/.test(hash)
|
||||
|| /^#open=notes¬e=/.test(hash);
|
||||
const hasSessionTarget = !!((hash && !isEntityHash) || Storage.get('lastSessionId'));
|
||||
if (hasSessionTarget) return;
|
||||
if (msgInput.value) {
|
||||
msgInput.value = '';
|
||||
|
||||
+14
-2
@@ -133,7 +133,7 @@ function _cleanAllowedHtmlOnce(htmlString) {
|
||||
return tpl.innerHTML;
|
||||
}
|
||||
|
||||
function sanitizeAllowedHtml(html) {
|
||||
export function sanitizeAllowedHtml(html) {
|
||||
const raw = String(html == null ? '' : html);
|
||||
// Non-browser context (e.g. a future SSR/Node import): fail closed by
|
||||
// escaping rather than trusting the markup.
|
||||
@@ -165,7 +165,7 @@ export function hasUnclosedThinkTag(text) {
|
||||
}
|
||||
|
||||
export function startsWithReasoningPrefix(text) {
|
||||
return /^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )/i.test(text || '');
|
||||
return /^\s*(?:thinking(?:\s+process)?\s*:|the user |user wants|we need |i need |i should |i will |i'll |i am going |let me (?:think|look|see|check|read|review|analyze|parse|figure|draft|write)|they are |the question |i can )/i.test(text || '');
|
||||
}
|
||||
|
||||
export function normalizeThinkingMarkup(text) {
|
||||
@@ -236,6 +236,11 @@ function normalizePlainThinking(text) {
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\s*(?:thinking(?:\s+process)?\s*:|the user |user wants|we need |let me (?:think|look|see|check|read|review|analyze|parse|figure|draft|write)|i need to |i should |i will |i'll |i am going )/i.test(trimmed)) {
|
||||
const thinkBlock = withoutPrefix.trim();
|
||||
if (thinkBlock) return `<think>${thinkBlock}</think>`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -560,6 +565,12 @@ export function mdToHtml(src, opts) {
|
||||
new RegExp(`(^|[^\\[(])#(${ANCHOR_KIND}-[A-Za-z0-9_-]+)\\b`, 'g'),
|
||||
'$1[#$2](#$2)',
|
||||
);
|
||||
// Legacy search_chats output used bare session hashes (`#<uuid>`). Upgrade
|
||||
// those too so old answers and model summaries remain clickable.
|
||||
s = s.replace(
|
||||
/(^|[^\[(])#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi,
|
||||
'$1[#session-$2](#session-$2)',
|
||||
);
|
||||
|
||||
// Convert markdown images before links so  does not become
|
||||
// literal "!" plus a normal link.
|
||||
@@ -824,6 +835,7 @@ export function renderMermaid(container) {
|
||||
const markdownModule = {
|
||||
escapeHtml,
|
||||
mdToHtml,
|
||||
sanitizeAllowedHtml,
|
||||
squashOutsideCode,
|
||||
renderContent,
|
||||
processWithThinking,
|
||||
|
||||
+20
-25
@@ -121,7 +121,7 @@ async function _ensureDefaultPendingChat() {
|
||||
if (!_deps || _defaultChatPickInFlight) return;
|
||||
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
|
||||
const pending = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (pending && pending.modelId) return;
|
||||
if (pending && pending.modelId && pending.source === 'manual') return;
|
||||
_defaultChatPickInFlight = true;
|
||||
try {
|
||||
await _ensureModelCacheForFallback();
|
||||
@@ -131,20 +131,26 @@ async function _ensureDefaultPendingChat() {
|
||||
if (res.ok) dc = await res.json();
|
||||
} catch (_) {}
|
||||
if (dc && dc.endpoint_url && dc.model && _modelExists(dc.model, dc.endpoint_url)) {
|
||||
const pendingUrl = String((pending && pending.url) || '').replace(/\/+$/, '');
|
||||
const defaultUrl = String(dc.endpoint_url || '').replace(/\/+$/, '');
|
||||
_deps.setPendingChat({
|
||||
url: dc.endpoint_url,
|
||||
modelId: dc.model,
|
||||
endpointId: dc.endpoint_id || '',
|
||||
source: 'default',
|
||||
});
|
||||
try { window.__odysseusDefaultChat = dc; } catch (_) {}
|
||||
updateModelPicker();
|
||||
if (!pending || pending.modelId !== dc.model || pendingUrl !== defaultUrl || pending.source !== 'default') {
|
||||
updateModelPicker();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pending && pending.modelId) return;
|
||||
// No configured default, or the configured default is gone/offline:
|
||||
// preserve the convenience fallback and keep the picker usable.
|
||||
const fallback = _firstAvailableModel();
|
||||
if (fallback) {
|
||||
_deps.setPendingChat(fallback);
|
||||
_deps.setPendingChat({ ...fallback, source: 'fallback' });
|
||||
updateModelPicker();
|
||||
}
|
||||
} finally {
|
||||
@@ -564,7 +570,7 @@ function _initModelPickerDropdown() {
|
||||
}
|
||||
if (!currentSessionId && _pendingChat) {
|
||||
// Already have a deferred session — just update the model
|
||||
_deps.setPendingChat({ url: m.url, modelId: m.mid, endpointId: m.endpointId });
|
||||
_deps.setPendingChat({ url: m.url, modelId: m.mid, endpointId: m.endpointId, source: 'manual' });
|
||||
// Header stays as session name — model switch only updates picker
|
||||
updateModelPicker();
|
||||
uiModule.showToast(`Using ${m.display}`);
|
||||
@@ -607,7 +613,7 @@ function _initModelPickerDropdown() {
|
||||
if ((current && current.model) || (pending && pending.modelId)) return;
|
||||
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
try { await window.modelsModule.refreshModels(true); } catch (_) {}
|
||||
try { await window.modelsModule.refreshModels(false); } catch (_) {}
|
||||
}
|
||||
const items = window.modelsModule && window.modelsModule.getCachedItems ? window.modelsModule.getCachedItems() : [];
|
||||
const targetEndpointId = detail.endpointId ? String(detail.endpointId) : '';
|
||||
@@ -656,12 +662,6 @@ function _initModelPickerDropdown() {
|
||||
updateModelPicker();
|
||||
}).catch(() => {});
|
||||
}
|
||||
// Kick off a local-endpoint probe — when it returns, re-render
|
||||
// the list so stale local servers get dimmed. Cloud entries
|
||||
// aren't probed; they stay visible.
|
||||
_refreshLocalProbe().then(() => {
|
||||
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
});
|
||||
if (window.innerWidth >= 768) search.focus();
|
||||
// Hide scroll button so it doesn't overlap
|
||||
const _scrollBtn = document.getElementById('scroll-bottom-btn');
|
||||
@@ -755,18 +755,6 @@ export function updateModelPicker() {
|
||||
// we have no session model and no pending-chat pick, fall through to
|
||||
// the "Select model" placeholder below.
|
||||
//
|
||||
// But if the server model cache already has an online endpoint, make the
|
||||
// same safe fallback visible in the picker immediately. The send path can
|
||||
// already resolve a usable model; the UI should not sit on "Select model"
|
||||
// and make it look broken.
|
||||
if (!modelId && !currentSessionId && window.modelsModule && window.modelsModule.getCachedItems) {
|
||||
const fallback = _firstAvailableModel();
|
||||
if (fallback) {
|
||||
_deps.setPendingChat(fallback);
|
||||
modelId = fallback.modelId;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if selected model is still available — fall back ONLY for pending chats with no user selection
|
||||
// Never override an existing session's model — the user explicitly chose it
|
||||
if (modelId && !currentSessionId && _pendingChat && window.modelsModule && window.modelsModule.getCachedItems) {
|
||||
@@ -781,11 +769,18 @@ export function updateModelPicker() {
|
||||
const fallback = items.find(item => !item.offline && (item.models || []).length > 0);
|
||||
if (fallback) {
|
||||
modelId = fallback.models[0];
|
||||
_deps.setPendingChat({ url: fallback.url, modelId, endpointId: fallback.endpoint_id });
|
||||
_deps.setPendingChat({ url: fallback.url, modelId, endpointId: fallback.endpoint_id, source: 'fallback' });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!modelId && !_autoSelectingDefault && window.modelsModule && window.modelsModule.getCachedItems) {
|
||||
const latestPending = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (
|
||||
!currentSessionId &&
|
||||
!_autoSelectingDefault &&
|
||||
window.modelsModule &&
|
||||
window.modelsModule.getCachedItems &&
|
||||
(!modelId || (latestPending && latestPending.source === 'fallback'))
|
||||
) {
|
||||
_ensureDefaultPendingChat();
|
||||
}
|
||||
|
||||
|
||||
+18
-9
@@ -17,6 +17,7 @@ let API_BASE = '';
|
||||
let _cachedItems = []; // cached /api/models items for model-switch dropdown
|
||||
let _lastFetchTime = 0;
|
||||
let _fetchInflight = null;
|
||||
let _fetchSeq = 0;
|
||||
const _FETCH_CACHE_TTL = 30000; // 30s client-side cache for /api/models
|
||||
const COLLAPSE_KEY = 'odysseus-models-collapsed';
|
||||
const FAVORITES_KEY = 'odysseus-model-favorites';
|
||||
@@ -165,18 +166,21 @@ function _buildModelRow(mid, url, displayName, endpointId, offline, modelType) {
|
||||
|
||||
export async function refreshModels(force = false) {
|
||||
const box = document.getElementById('models');
|
||||
if (!box) return;
|
||||
|
||||
// Skip network fetch if cache is fresh and not forced — still re-render UI
|
||||
const now = Date.now();
|
||||
const needsFetch = force || _cachedItems.length === 0 || (now - _lastFetchTime) >= _FETCH_CACHE_TTL;
|
||||
|
||||
box.innerHTML = '';
|
||||
if (box) box.innerHTML = '';
|
||||
if (needsFetch) {
|
||||
const _loadingSpinner = spinnerModule.create('', 'right', 'wave');
|
||||
box.appendChild(_loadingSpinner.createElement());
|
||||
_loadingSpinner.start();
|
||||
let _loadingSpinner = null;
|
||||
if (box) {
|
||||
_loadingSpinner = spinnerModule.create('', 'right', 'wave');
|
||||
box.appendChild(_loadingSpinner.createElement());
|
||||
_loadingSpinner.start();
|
||||
}
|
||||
try {
|
||||
if (force) _fetchInflight = null;
|
||||
if (!_fetchInflight) {
|
||||
// Pass ?refresh=true on forced refreshes so the BACKEND's 30s
|
||||
// per-user cache also gets bypassed. Without this, `force=true`
|
||||
@@ -184,25 +188,30 @@ export async function refreshModels(force = false) {
|
||||
// back — newly-served endpoints don't appear until the cache
|
||||
// ages out. (Bug repro: serve a model, picker is empty for ~30s
|
||||
// even though the endpoint is in the DB and online.)
|
||||
const _seq = ++_fetchSeq;
|
||||
const _url = `${API_BASE}/api/models` + (force ? '?refresh=true' : '?background=false');
|
||||
_fetchInflight = fetch(_url, { credentials: 'same-origin' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
const data = await res.json();
|
||||
return { data, seq: _seq };
|
||||
})
|
||||
.finally(() => { _fetchInflight = null; });
|
||||
}
|
||||
const data = await _fetchInflight;
|
||||
const { data, seq } = await _fetchInflight;
|
||||
if (seq < _fetchSeq) return;
|
||||
_lastFetchTime = Date.now();
|
||||
_cachedItems = data.items || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
box.textContent = '(scan failed)';
|
||||
if (box) box.textContent = '(scan failed)';
|
||||
return;
|
||||
} finally {
|
||||
box.innerHTML = '';
|
||||
try { _loadingSpinner && _loadingSpinner.stop && _loadingSpinner.stop(); } catch (_) {}
|
||||
if (box) box.innerHTML = '';
|
||||
}
|
||||
}
|
||||
if (!box) return;
|
||||
try {
|
||||
|
||||
const collapseState = _loadCollapsed();
|
||||
|
||||
+20
-19
@@ -5322,25 +5322,26 @@ async function _initReminders() {
|
||||
// just opening the panel when the card isn't found (panel still
|
||||
// loading, note in a different filter, etc.).
|
||||
async function openNote(noteId) {
|
||||
// If the panel is already open, openPanel() short-circuits and does
|
||||
// nothing — including no re-fetch — so a freshly-created note added
|
||||
// server-side never shows up. Force a refresh by closing first when
|
||||
// open, then re-opening. Clicking the sidebar Notes button as a
|
||||
// last resort keeps this working even if the module state got out
|
||||
// of sync (rare but seen during HMR or after a stuck modal).
|
||||
try {
|
||||
if (isPanelOpen && isPanelOpen()) {
|
||||
closePanel();
|
||||
// give the close animation a frame to settle
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
}
|
||||
} catch (_) {}
|
||||
openPanel();
|
||||
// openPanel() kicks off _fetchNotes() asynchronously, so the cards
|
||||
// for newly-created notes may not be in the DOM yet. Also poll the
|
||||
// _notes module array directly — if the note IS loaded but the
|
||||
// active filter (e.g. archive view) is hiding it, we can still
|
||||
// surface a confirmation toast.
|
||||
const wasOpen = !!(isPanelOpen && isPanelOpen());
|
||||
_showingArchived = false;
|
||||
_activeLabel = null;
|
||||
_activeFilter = null;
|
||||
_searchQuery = '';
|
||||
if (!wasOpen) {
|
||||
openPanel();
|
||||
} else {
|
||||
_bringNotesToFront();
|
||||
const searchEl = document.getElementById('notes-search');
|
||||
if (searchEl) searchEl.value = '';
|
||||
const pane = document.getElementById('notes-pane');
|
||||
if (pane) pane.classList.remove('notes-pane-archive');
|
||||
const archiveBtn = document.getElementById('notes-archive-toggle');
|
||||
if (archiveBtn) archiveBtn.classList.remove('active');
|
||||
await _fetchNotes();
|
||||
_renderNotes();
|
||||
}
|
||||
// openPanel() kicks off _fetchNotes() asynchronously, so the cards for
|
||||
// newly-created notes may not be in the DOM yet. Poll until the card exists.
|
||||
if (!noteId) return;
|
||||
let tries = 0;
|
||||
const findAndFlash = () => {
|
||||
|
||||
+53
-62
@@ -3,7 +3,6 @@
|
||||
|
||||
import Storage from './storage.js';
|
||||
import uiModule, { autoResize, styledPrompt } from './ui.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import chatRenderer from './chatRenderer.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { initModelPicker, updateModelPicker } from './modelPicker.js';
|
||||
@@ -110,6 +109,29 @@ function _historyUrl(id, { limit = null, offset = null } = {}) {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function _addHistoryMessageWithFullRenderer(role, content, modelName, meta) {
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) return [];
|
||||
const marker = document.createComment('history-message');
|
||||
box.appendChild(marker);
|
||||
let rendered = null;
|
||||
try {
|
||||
rendered = chatRenderer.addMessage(role, content, modelName, meta);
|
||||
} catch (e) {
|
||||
marker.remove();
|
||||
throw e;
|
||||
}
|
||||
const nodes = [];
|
||||
let node = marker.nextSibling;
|
||||
while (node) {
|
||||
const next = node.nextSibling;
|
||||
nodes.push(node);
|
||||
node = next;
|
||||
}
|
||||
marker.remove();
|
||||
return nodes.length ? nodes : (rendered ? [rendered] : []);
|
||||
}
|
||||
|
||||
function _renderHistoryMessage(msg, modelName) {
|
||||
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
|
||||
let displayContent;
|
||||
@@ -137,54 +159,7 @@ function _renderHistoryMessage(msg, modelName) {
|
||||
displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`;
|
||||
}
|
||||
}
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) return null;
|
||||
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'msg ' + (msg.role === 'user' ? 'msg-user' : 'msg-ai');
|
||||
wrap.dataset.raw = displayContent;
|
||||
if (meta?._db_id) wrap.dataset.dbId = meta._db_id;
|
||||
|
||||
const roleEl = document.createElement('div');
|
||||
roleEl.className = 'role';
|
||||
if (msg.role === 'user') {
|
||||
roleEl.textContent = 'You';
|
||||
} else {
|
||||
const pair = chatRenderer.replyModelPair ? chatRenderer.replyModelPair(modelName, meta) : {};
|
||||
const resolved = pair.actualModel || pair.requestedModel || modelName;
|
||||
roleEl.textContent = chatRenderer.modelRouteLabel
|
||||
? chatRenderer.modelRouteLabel(pair.requestedModel, resolved)
|
||||
: (resolved || 'Odysseus');
|
||||
if (chatRenderer.applyModelColor) chatRenderer.applyModelColor(roleEl, resolved);
|
||||
}
|
||||
const timestamp = meta?.timestamp;
|
||||
if (timestamp) {
|
||||
const ts = document.createElement('span');
|
||||
ts.className = 'msg-time';
|
||||
try {
|
||||
ts.textContent = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
ts.textContent = '';
|
||||
}
|
||||
roleEl.appendChild(ts);
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'body';
|
||||
body.innerHTML = markdownModule.processWithThinking(
|
||||
markdownModule.squashOutsideCode(markdownModule.renderContent(displayContent || ''))
|
||||
);
|
||||
if (msg.role === 'user' && Array.isArray(meta?.attachments) && meta.attachments.length) {
|
||||
if (chatRenderer.buildAttachCards) {
|
||||
body.appendChild(chatRenderer.buildAttachCards(meta.attachments));
|
||||
}
|
||||
}
|
||||
|
||||
wrap.appendChild(roleEl);
|
||||
wrap.appendChild(body);
|
||||
box.appendChild(wrap);
|
||||
return wrap;
|
||||
return _addHistoryMessageWithFullRenderer(msg.role, displayContent, modelName, meta);
|
||||
}
|
||||
|
||||
function _clearHistoryPager() {
|
||||
@@ -232,8 +207,8 @@ function _installHistoryPager(id, pageInfo, modelName) {
|
||||
const newEls = [];
|
||||
for (const msg of data.history || []) {
|
||||
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
|
||||
const el = _renderHistoryMessage(msg, _historyPager.modelName);
|
||||
if (el) newEls.push(el);
|
||||
const els = _renderHistoryMessage(msg, _historyPager.modelName);
|
||||
if (Array.isArray(els)) newEls.push(...els);
|
||||
}
|
||||
for (const el of newEls) {
|
||||
box.insertBefore(el, anchor || box.firstChild);
|
||||
@@ -1661,7 +1636,10 @@ export async function loadSessions() {
|
||||
// most recently appended a message.
|
||||
const _isTransient = (s) => !!s && (s.folder === 'Assistant' || s.folder === 'Tasks');
|
||||
const _realSessions = activeSessions.filter(s => !_isTransient(s));
|
||||
const hashId = window.location.hash.replace('#', '');
|
||||
let hashId = window.location.hash.replace('#', '');
|
||||
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId) || /^open=notes¬e=/.test(hashId)) {
|
||||
hashId = '';
|
||||
}
|
||||
let savedId = Storage.get('lastSessionId');
|
||||
// If the persisted lastSessionId points to a transient session (legacy
|
||||
// state from before the persistence-guard was added), drop it.
|
||||
@@ -1780,7 +1758,7 @@ export async function loadSessions() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectSession(id, { keepSidebar = false, showLoading = true } = {}) {
|
||||
export async function selectSession(id, { keepSidebar = false, showLoading = true, immediateLoading = false } = {}) {
|
||||
// Exit compare mode cleanly if active
|
||||
if (window.compareModule && window.compareModule.isActive()) {
|
||||
window.compareModule.deactivate(true);
|
||||
@@ -1898,7 +1876,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
let loadingPaintReady = Promise.resolve();
|
||||
if (!isOC) {
|
||||
if (showLoading && chatHistory && prevSessionId !== id) {
|
||||
const loadingDelayMs = window.innerWidth <= 768 ? 900 : 500;
|
||||
const loadingDelayMs = immediateLoading ? 0 : (window.innerWidth <= 768 ? 900 : 500);
|
||||
loadingTimer = setTimeout(() => {
|
||||
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
|
||||
_paintSessionLoading(chatHistory, 'Loading chat');
|
||||
@@ -1990,8 +1968,13 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
}
|
||||
} else {
|
||||
if (window.chatModule && window.chatModule.showWelcomeScreen) window.chatModule.showWelcomeScreen();
|
||||
// Don't highlight empty sessions — feels like nothing is selected
|
||||
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
|
||||
// Don't highlight ordinary empty sessions — feels like nothing is
|
||||
// selected. Keep document/email-scoped sessions highlighted though: a
|
||||
// new email/reply chat starts empty but immediately owns an email doc.
|
||||
const isDocScopedEmptySession = !!(meta && (meta.has_documents || /^Email:|^New Email$/i.test(meta.name || '')));
|
||||
if (!isDocScopedEmptySession) {
|
||||
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
|
||||
}
|
||||
}
|
||||
uiModule.scrollHistoryInstant();
|
||||
if (!isOC && msgHistory.length) {
|
||||
@@ -2078,9 +2061,16 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
}
|
||||
uiModule.showError('Failed to load session: ' + error.message);
|
||||
} finally {
|
||||
// Ensure memories are loaded after session selection
|
||||
// Memory warmup must not block chat switching. The memories panel can load
|
||||
// on demand; this is only a delayed cache refresh when the foreground chat
|
||||
// is idle.
|
||||
if (window.memoryModule && window.memoryModule.loadMemories) {
|
||||
await window.memoryModule.loadMemories();
|
||||
setTimeout(() => {
|
||||
const busy = !!window.__odysseusChatBusy
|
||||
|| Date.now() < (window.__odysseusChatBusyUntil || 0)
|
||||
|| !!document.querySelector('.send-btn[data-mode="streaming"], .send-btn.send-pending');
|
||||
if (!busy) window.memoryModule.loadMemories().catch(() => {});
|
||||
}, 2500);
|
||||
}
|
||||
// Auto-focus message input (unless session list has keyboard focus).
|
||||
// Skip on mobile — focusing the textarea pops up the on-screen keyboard,
|
||||
@@ -2208,10 +2198,11 @@ export async function materializePendingSession() {
|
||||
Storage.set('lastSessionId', payload.id);
|
||||
history.replaceState(null, '', '#' + payload.id);
|
||||
|
||||
// Reload sidebar to show the new session — await it so the session
|
||||
// is fully registered before the caller proceeds (prevents race conditions)
|
||||
// Reload the sidebar in the background. Awaiting this used to block the first
|
||||
// prompt in a new/pending chat behind startup fetches and slow /api/sessions
|
||||
// calls, so the user's message could sit for 20s+ before streaming began.
|
||||
_suppressNextSessionLoading = true;
|
||||
await loadSessions().catch(() => {});
|
||||
loadSessions().catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2345,7 +2336,7 @@ export function initDragSort() {
|
||||
// session navigation (which would reset the active chat).
|
||||
window.addEventListener('hashchange', () => {
|
||||
const hashId = window.location.hash.replace('#', '');
|
||||
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId)) return;
|
||||
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId) || /^open=notes¬e=/.test(hashId)) return;
|
||||
if (hashId && hashId !== currentSessionId) {
|
||||
const target = sessions.find(s => s.id === hashId && !s.archived);
|
||||
if (target) selectSession(hashId);
|
||||
|
||||
+29
-16
@@ -3106,6 +3106,28 @@ async function initEmailSettings() {
|
||||
const root = el('settings-modal');
|
||||
if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
|
||||
|
||||
const styleKey = 'odysseus-email-writing-style';
|
||||
const styleEl = el('set-email-style');
|
||||
|
||||
// The account/CardDAV config endpoints can be slow when remote mail servers
|
||||
// are cold. Populate the Writing Style box independently so saved prose does
|
||||
// not appear seconds after the panel opens.
|
||||
try {
|
||||
const cachedStyle = localStorage.getItem(styleKey);
|
||||
if (styleEl && cachedStyle !== null && !styleEl.value) styleEl.value = cachedStyle;
|
||||
} catch (_) {}
|
||||
|
||||
const loadWritingStyle = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/email/style');
|
||||
const data = await res.json();
|
||||
const style = data.style || '';
|
||||
if (styleEl) styleEl.value = style;
|
||||
try { localStorage.setItem(styleKey, style); } catch (_) {}
|
||||
} catch (_) {}
|
||||
};
|
||||
loadWritingStyle();
|
||||
|
||||
// Load current email config
|
||||
try {
|
||||
const res = await fetch('/api/email/config');
|
||||
@@ -3119,8 +3141,6 @@ async function initEmailSettings() {
|
||||
if (el('set-email-smtp-user')) el('set-email-smtp-user').value = cfg.smtp_user || '';
|
||||
if (el('set-email-smtp-pass')) el('set-email-smtp-pass').value = '';
|
||||
if (el('set-email-from')) el('set-email-from').value = cfg.from_address || '';
|
||||
if (el('set-email-auto-translate')) el('set-email-auto-translate').checked = !!cfg.email_auto_translate;
|
||||
if (el('set-email-translate-language')) el('set-email-translate-language').value = cfg.email_translate_language || 'English';
|
||||
} catch (_) {}
|
||||
|
||||
// Load contacts config
|
||||
@@ -3132,13 +3152,6 @@ async function initEmailSettings() {
|
||||
if (el('set-carddav-pass')) el('set-carddav-pass').value = '';
|
||||
} catch (_) {}
|
||||
|
||||
// Load writing style
|
||||
try {
|
||||
const res = await fetch('/api/email/style');
|
||||
const data = await res.json();
|
||||
if (el('set-email-style')) el('set-email-style').value = data.style || '';
|
||||
} catch (_) {}
|
||||
|
||||
// Save email config
|
||||
el('set-email-save')?.addEventListener('click', async () => {
|
||||
const msg = el('set-email-msg');
|
||||
@@ -3151,8 +3164,6 @@ async function initEmailSettings() {
|
||||
smtp_port: parseInt(el('set-email-smtp-port').value) || 0,
|
||||
smtp_user: el('set-email-smtp-user').value,
|
||||
email_from: el('set-email-from').value,
|
||||
email_auto_translate: !!el('set-email-auto-translate')?.checked,
|
||||
email_translate_language: (el('set-email-translate-language')?.value || 'English').trim() || 'English',
|
||||
};
|
||||
const imapPass = el('set-email-imap-pass').value;
|
||||
const smtpPass = el('set-email-smtp-pass').value;
|
||||
@@ -3166,10 +3177,7 @@ async function initEmailSettings() {
|
||||
});
|
||||
const result = await res.json();
|
||||
if (msg) msg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
|
||||
const translateMsg = el('set-email-translate-msg');
|
||||
if (translateMsg) translateMsg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
setTimeout(() => { const translateMsg = el('set-email-translate-msg'); if (translateMsg) translateMsg.textContent = ''; }, 3000);
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = 'Failed';
|
||||
}
|
||||
@@ -3234,7 +3242,8 @@ async function initEmailSettings() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.style) {
|
||||
if (el('set-email-style')) el('set-email-style').value = data.style;
|
||||
if (styleEl) styleEl.value = data.style;
|
||||
try { localStorage.setItem(styleKey, data.style); } catch (_) {}
|
||||
if (msg) msg.textContent = '✓ Style extracted';
|
||||
} else {
|
||||
if (msg) msg.textContent = data.error || 'Failed';
|
||||
@@ -3253,12 +3262,16 @@ async function initEmailSettings() {
|
||||
const msg = el('set-email-style-msg');
|
||||
if (msg) msg.textContent = 'Saving...';
|
||||
try {
|
||||
const style = styleEl ? styleEl.value : '';
|
||||
const res = await fetch('/api/email/style', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ style: el('set-email-style').value }),
|
||||
body: JSON.stringify({ style }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
try { localStorage.setItem(styleKey, style); } catch (_) {}
|
||||
}
|
||||
if (msg) msg.textContent = result.success ? '✓ Saved' : 'Failed';
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
} catch (e) {
|
||||
|
||||
@@ -92,10 +92,11 @@ export function initSidebarLayout(Storage, opts) {
|
||||
});
|
||||
}
|
||||
|
||||
// New chat buttons — same as clicking brand
|
||||
// Header-only new-chat aliases. #sidebar-new-chat-btn is wired in app.js
|
||||
// because it needs the full default-model/pending-chat flow; wiring it here
|
||||
// as well caused duplicate click handling and occasional no-op/race behavior.
|
||||
const chatNewBtn = document.getElementById('chat-new-btn');
|
||||
const sidebarNewChat = document.getElementById('sidebar-new-chat-btn');
|
||||
[chatNewBtn, sidebarNewChat].forEach(btn => {
|
||||
[chatNewBtn].forEach(btn => {
|
||||
if (btn) btn.addEventListener('click', () => {
|
||||
const brandBtn = document.getElementById('sidebar-brand-btn');
|
||||
if (brandBtn) brandBtn.click();
|
||||
|
||||
+754
-11
@@ -3579,6 +3579,10 @@ body.bg-pattern-sparkles {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.footer-copy-btn:hover { color:var(--accent); }
|
||||
.footer-open-gallery-btn {
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* Delete action — same chrome as copy/download/edit but hover reveals
|
||||
the destructive red tint so the user can tell it's not a benign op. */
|
||||
.footer-delete-btn:hover { color: var(--red); }
|
||||
@@ -4135,7 +4139,7 @@ body.bg-pattern-sparkles {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
.toast-close-btn {
|
||||
margin-left: 8px;
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -8168,6 +8172,11 @@ button.hamburger {
|
||||
.msg a:hover {
|
||||
border-bottom-color: var(--hl-function, #5b8def);
|
||||
}
|
||||
.msg a.is-loading {
|
||||
opacity: 0.65;
|
||||
border-bottom-color: var(--accent, #bd93f9);
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.msg table {
|
||||
@@ -8675,6 +8684,98 @@ button.hamburger {
|
||||
.thumb.thumb-image button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.attach-crop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--attach-crop-z, 100001);
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.attach-crop-panel {
|
||||
width: min(94vw, 520px);
|
||||
max-height: min(86vh, 720px);
|
||||
background: var(--panel, #111);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.attach-crop-stage {
|
||||
position: relative;
|
||||
min-height: 280px;
|
||||
height: min(66vh, 560px);
|
||||
background: #050505;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
.attach-crop-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.attach-crop-box {
|
||||
position: absolute;
|
||||
border: 2px solid var(--accent, var(--red));
|
||||
box-shadow: 0 0 0 9999px rgba(0,0,0,0.42);
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.attach-crop-box::before,
|
||||
.attach-crop-box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 33.333% 0 auto;
|
||||
border-top: 1px solid rgba(255,255,255,0.38);
|
||||
}
|
||||
.attach-crop-box::after {
|
||||
inset: 66.666% 0 auto;
|
||||
}
|
||||
.attach-crop-handle {
|
||||
position: absolute;
|
||||
right: -9px;
|
||||
bottom: -9px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, var(--red));
|
||||
border: 2px solid var(--bg);
|
||||
box-sizing: border-box;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.attach-crop-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: color-mix(in srgb, var(--bg) 78%, transparent);
|
||||
}
|
||||
.attach-crop-btn {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.attach-crop-primary {
|
||||
background: var(--accent, var(--red));
|
||||
color: #fff;
|
||||
border-color: var(--accent, var(--red));
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
/* Collapsed "N files" badge: use the same corner-X accent badge as image thumbs. */
|
||||
.thumb-collapsed { position: relative; }
|
||||
@@ -18075,6 +18176,10 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
.gallery-card.gallery-card-focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 36%, transparent);
|
||||
}
|
||||
/* Upload-affordance tile pinned to the top-left of the Photos grid. Matches
|
||||
the Upload album tile in the Albums tab — dashed border, centered icon. */
|
||||
.gallery-card-upload {
|
||||
@@ -19067,6 +19172,14 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
}
|
||||
.cookbook-field-input:focus { border-color: var(--red); }
|
||||
#hwfit-dl-server, #hwfit-usecase, #hwfit-server-select, #hwfit-cache-server, #serve-sort { height: 28px; width: 88px; }
|
||||
#hwfit-dl-server.cookbook-server-select-colored,
|
||||
#hwfit-server-select.cookbook-server-select-colored,
|
||||
#hwfit-cache-server.cookbook-server-select-colored,
|
||||
#hwfit-deps-server.cookbook-server-select-colored {
|
||||
color: var(--cookbook-server-color, var(--fg));
|
||||
border-color: color-mix(in srgb, var(--cookbook-server-color) 55%, var(--border));
|
||||
background-color: color-mix(in srgb, var(--cookbook-server-color) 12%, var(--bg));
|
||||
}
|
||||
/* Serve tab — match Documents sizing, but leave room for the active "Cancel"
|
||||
label and center it vertically so the descenders don't clip. */
|
||||
#hwfit-cache-select {
|
||||
@@ -19723,6 +19836,25 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
background: color-mix(in srgb, var(--fg) 9%, transparent);
|
||||
border-color: color-mix(in srgb, var(--fg) 22%, transparent);
|
||||
}
|
||||
.cookbook-section-header.has-server-color {
|
||||
color: var(--fg);
|
||||
border-color: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 4%, var(--bg));
|
||||
box-shadow: inset 6px 0 0 var(--cookbook-server-accent, var(--cookbook-server-color));
|
||||
}
|
||||
.cookbook-section-header.has-server-color:hover {
|
||||
background: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 7%, var(--bg));
|
||||
border-color: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 60%, var(--border));
|
||||
}
|
||||
.cookbook-section-header.has-server-color .cookbook-section-title,
|
||||
.cookbook-section-header.has-server-color .cookbook-section-chevron {
|
||||
color: var(--cookbook-server-accent, var(--cookbook-server-color));
|
||||
}
|
||||
.cookbook-section-header.has-server-color .cookbook-srv-status.ok {
|
||||
background: var(--cookbook-server-accent, var(--cookbook-server-color));
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 18%, transparent),
|
||||
0 0 8px color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 55%, transparent);
|
||||
}
|
||||
.cookbook-section-header .cookbook-section-title {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -20795,6 +20927,12 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
border-color: var(--border);
|
||||
color: var(--fg);
|
||||
}
|
||||
.cookbook-task-menu-btn.cookbook-menu-active {
|
||||
opacity: 1 !important;
|
||||
background: color-mix(in srgb, var(--fg) 9%, transparent);
|
||||
border-color: var(--border);
|
||||
color: var(--fg);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.cookbook-task .cookbook-task-menu-btn {
|
||||
opacity: 0.72;
|
||||
@@ -22146,7 +22284,7 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid color-mix(in srgb, var(--fg) 30%, transparent);
|
||||
border-left: 3px solid var(--cookbook-server-color, color-mix(in srgb, var(--fg) 30%, transparent));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--fg) 3%, var(--bg));
|
||||
box-sizing: border-box;
|
||||
@@ -22159,6 +22297,113 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
.cookbook-server-row .cookbook-srv-path { flex: 1 1 100% !important; width: auto !important; min-width: 0 !important; }
|
||||
}
|
||||
.cookbook-server-row .cookbook-srv-name { width: 60px; flex-shrink: 0; flex-grow: 0; }
|
||||
.cookbook-server-row .cookbook-srv-color-wrap {
|
||||
width: 86px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
color: var(--cookbook-server-color, var(--fg-muted));
|
||||
}
|
||||
.cookbook-server-row select.cookbook-srv-color {
|
||||
display: none !important;
|
||||
}
|
||||
.cookbook-server-row .cookbook-srv-color-dot {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.35;
|
||||
z-index: 1;
|
||||
}
|
||||
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-dot {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.cookbook-server-row .cookbook-srv-color-btn {
|
||||
width: 100%;
|
||||
padding-left: 22px !important;
|
||||
padding-right: 18px !important;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cookbook-server-row .cookbook-srv-color-label {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cookbook-server-row .cookbook-srv-color-caret {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0.65;
|
||||
}
|
||||
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-btn {
|
||||
color: var(--fg);
|
||||
border-color: color-mix(in srgb, var(--cookbook-server-color, var(--border)) 55%, var(--border));
|
||||
background-color: color-mix(in srgb, var(--cookbook-server-color, var(--fg)) 14%, var(--bg));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--cookbook-server-color, var(--border)) 18%, transparent);
|
||||
}
|
||||
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-dot {
|
||||
background: var(--cookbook-server-color, currentColor);
|
||||
}
|
||||
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-caret {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.cookbook-srv-color-menu {
|
||||
position: absolute;
|
||||
z-index: 5000;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
width: 138px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, #000 35%, transparent);
|
||||
}
|
||||
.cookbook-srv-color-menu.hidden {
|
||||
display: none;
|
||||
}
|
||||
.cookbook-srv-color-item {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--swatch-color, var(--fg)) 10%, transparent);
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 7px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.cookbook-srv-color-item:hover,
|
||||
.cookbook-srv-color-item.active {
|
||||
border-color: color-mix(in srgb, var(--swatch-color, var(--accent, var(--red))) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--swatch-color, var(--accent, var(--red))) 18%, var(--bg));
|
||||
}
|
||||
.cookbook-srv-color-item-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--swatch-color, var(--fg-muted));
|
||||
border: 1px solid color-mix(in srgb, var(--fg) 18%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cookbook-server-row .cookbook-srv-host { flex: 1; min-width: 100px; }
|
||||
.cookbook-server-row .cookbook-srv-host[readonly] { opacity: 0.4; cursor: default; }
|
||||
.cookbook-server-row .cookbook-srv-port { width: 40px; flex-shrink: 0; flex-grow: 0; }
|
||||
@@ -24197,6 +24442,36 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Deep Research settings contain desktop-sized inline widths. On mobile,
|
||||
let each control claim the card width instead of escaping the admin block. */
|
||||
.settings-row:has(#set-researchSearch),
|
||||
.settings-row:has(#set-researchMaxTokens),
|
||||
.settings-row:has(#set-researchExtractTimeout),
|
||||
.settings-row:has(#set-researchExtractConcurrency),
|
||||
.settings-row:has(#set-researchRunTimeout) {
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
.settings-row:has(#set-researchSearch) .settings-label,
|
||||
.settings-row:has(#set-researchMaxTokens) .settings-label,
|
||||
.settings-row:has(#set-researchExtractTimeout) .settings-label,
|
||||
.settings-row:has(#set-researchExtractConcurrency) .settings-label,
|
||||
.settings-row:has(#set-researchRunTimeout) .settings-label {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
#set-researchSearch,
|
||||
#set-researchMaxTokens,
|
||||
#set-researchRunTimeout,
|
||||
.settings-row div:has(> #set-researchExtractTimeout),
|
||||
.settings-row div:has(> #set-researchExtractConcurrency) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
flex: 1 1 100% !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Default-chat fallback chain editor. Each row mirrors the primary
|
||||
endpoint/model selectors, indented under them to read as a chain. */
|
||||
.settings-fallbacks {
|
||||
@@ -24735,8 +25010,17 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
|
||||
}
|
||||
#hwfit-cache-scan {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
width: 51px;
|
||||
top: -1px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#hwfit-cache-scan.spinning svg {
|
||||
animation: modelPickerRefreshSpin 0.75s linear infinite;
|
||||
}
|
||||
#serve-search {
|
||||
height: 28px;
|
||||
@@ -25996,6 +26280,126 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
|
||||
background: color-mix(in srgb, var(--bg) 30%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ge-ai-command {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 12px;
|
||||
z-index: 18;
|
||||
width: min(430px, calc(100% - 28px));
|
||||
transform: translateX(-50%);
|
||||
padding: 7px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 42%, var(--border));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel, var(--bg)) 94%, transparent);
|
||||
box-shadow: 0 8px 22px color-mix(in srgb, #000 26%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.ge-ai-command-toggle {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--accent, var(--red));
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ge-ai-command-collapsed {
|
||||
width: auto;
|
||||
min-width: 112px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.ge-ai-command-collapsed .ge-ai-command-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
.ge-ai-command-collapsed .ge-ai-command-form,
|
||||
.ge-ai-command-collapsed .ge-ai-command-status {
|
||||
display: none;
|
||||
}
|
||||
.ge-ai-command-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.ge-ai-command-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg) 76%, #000 24%);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ge-ai-command-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent, var(--red));
|
||||
}
|
||||
.ge-ai-command-run {
|
||||
background: var(--send-btn-bg, var(--red));
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
min-width: 32px;
|
||||
width: 32px;
|
||||
height: 32px !important;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
transition: background 0.25s, opacity 0.12s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ge-ai-command-run:hover {
|
||||
background: var(--send-btn-hover, color-mix(in srgb, var(--red) 80%, white));
|
||||
}
|
||||
.ge-ai-command-close {
|
||||
width: 28px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
opacity: 0.52;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.ge-ai-command-close:hover {
|
||||
opacity: 0.9;
|
||||
background: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
}
|
||||
.ge-ai-command-status {
|
||||
min-height: 13px;
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.ge-ai-command-status[data-kind="running"] {
|
||||
color: var(--accent, var(--red));
|
||||
}
|
||||
.ge-ai-command-status[data-kind="done"] {
|
||||
color: var(--green, #4caf50);
|
||||
}
|
||||
.ge-ai-command-status[data-kind="error"] {
|
||||
color: var(--red, #e5484d);
|
||||
}
|
||||
/* Loading overlay shown while a large image is decoding — centered
|
||||
whirlpool + "Loading" caption over a translucent dim. */
|
||||
.ge-loading-overlay {
|
||||
@@ -30891,6 +31295,39 @@ button .spinner-whirlpool {
|
||||
cursor: pointer; user-select: none;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.email-attachments-download-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--red)) 9%, transparent);
|
||||
color: var(--accent-primary, var(--red));
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.email-attachments-download-all:hover {
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--red)) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 60%, transparent);
|
||||
}
|
||||
.email-attachments-download-all.is-loading {
|
||||
opacity: 0.82;
|
||||
pointer-events: none;
|
||||
}
|
||||
.email-attachments-download-all .spinner-whirlpool,
|
||||
.email-attachments-download-all .ai-spinner-whirlpool {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.email-reader-atts-wrap > .email-reader-atts {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
@@ -31503,6 +31940,34 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
.email-filter-chip-clear:hover { opacity: 1; }
|
||||
.email-date { font-size: 10px; opacity: 0.5; white-space: nowrap; flex-shrink: 0; }
|
||||
.email-subject { font-size: 11px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
||||
.email-sent-chip,
|
||||
.email-folder-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 14px;
|
||||
padding: 0 5px;
|
||||
margin-right: 5px;
|
||||
border-radius: 999px;
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 28%, transparent);
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.email-folder-chip {
|
||||
color: color-mix(in srgb, var(--fg) 75%, transparent);
|
||||
background: rgba(127, 127, 127, 0.13);
|
||||
border-color: rgba(127, 127, 127, 0.24);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.email-search-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
.email-tags { display: inline-flex; gap: 3px; margin-left: 6px; vertical-align: middle; position: relative; top: -2px; }
|
||||
.email-card-expanded .email-tags,
|
||||
.email-card-reader .email-tags { top: -4px; }
|
||||
@@ -32259,23 +32724,269 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
|
||||
/* Compose attachment chips (when sending new email) */
|
||||
.email-compose-atts {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
padding: 6px 0 0 58px;
|
||||
display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 6px;
|
||||
padding: 6px 0 0 0;
|
||||
}
|
||||
.email-compose-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: min(240px, 100%);
|
||||
padding: 4px 4px 4px 8px; font-size: 11px;
|
||||
background: var(--hover-bg, rgba(255,255,255,0.05));
|
||||
border: 1px solid var(--border); border-radius: 12px; color: var(--fg);
|
||||
}
|
||||
.email-compose-chip .compose-chip-name { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.email-compose-chip .att-size { opacity: 0.5; font-size: 10px; }
|
||||
.email-compose-chip .compose-chip-name { max-width: 180px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.email-compose-chip .att-size { opacity: 0.5; font-size: 10px; flex-shrink: 0; }
|
||||
.email-compose-chip .compose-chip-remove {
|
||||
background: none; border: none; color: var(--fg); opacity: 0.5;
|
||||
font-size: 16px; line-height: 1; padding: 0 4px; cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.email-compose-chip .compose-chip-remove:hover { opacity: 1; color: var(--red, #e55); }
|
||||
|
||||
.email-odysseus-attach-menu {
|
||||
position: fixed;
|
||||
z-index: 10050;
|
||||
width: 300px;
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: min(420px, calc(100vh - 16px));
|
||||
padding: 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-odysseus-attach-local {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 35%, var(--border));
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--red)) 9%, transparent);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.email-odysseus-attach-local:hover {
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--red)) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 55%, var(--border));
|
||||
}
|
||||
.email-odysseus-attach-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
margin: 7px 0;
|
||||
}
|
||||
.email-odysseus-attach-tabs button {
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
opacity: 0.72;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.email-odysseus-attach-tabs button.active {
|
||||
opacity: 1;
|
||||
color: var(--accent-primary, var(--red));
|
||||
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--red)) 8%, transparent);
|
||||
}
|
||||
.email-odysseus-attach-tabs button svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.email-odysseus-attach-search-wrap {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: -1px 0 7px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--fg) 3%, transparent);
|
||||
color: color-mix(in srgb, var(--fg) 48%, transparent);
|
||||
}
|
||||
.email-odysseus-attach-search-wrap:focus-within {
|
||||
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
|
||||
color: var(--accent-primary, var(--red));
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--red)) 6%, transparent);
|
||||
}
|
||||
.email-odysseus-attach-search {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
.email-odysseus-attach-search::placeholder {
|
||||
color: color-mix(in srgb, var(--fg) 38%, transparent);
|
||||
}
|
||||
.email-odysseus-attach-list {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.email-odysseus-attach-row {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.email-odysseus-attach-row:hover {
|
||||
background: var(--hover-bg, rgba(255,255,255,0.06));
|
||||
border-color: var(--border);
|
||||
}
|
||||
.email-odysseus-attach-row.is-selected {
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--red)) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
|
||||
}
|
||||
.email-odysseus-attach-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--fg) 4%, transparent);
|
||||
}
|
||||
.email-odysseus-attach-row.is-selected .email-odysseus-attach-dot {
|
||||
border-color: var(--accent-primary, var(--red));
|
||||
background: var(--accent-primary, var(--red));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-primary, var(--red)) 18%, transparent);
|
||||
}
|
||||
.email-odysseus-attach-icon,
|
||||
.email-odysseus-attach-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fg) 7%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-odysseus-attach-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.email-odysseus-attach-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.email-odysseus-attach-title,
|
||||
.email-odysseus-attach-meta {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.email-odysseus-attach-title {
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.email-odysseus-attach-meta {
|
||||
font-size: 10px;
|
||||
line-height: 1.15;
|
||||
opacity: 0.52;
|
||||
}
|
||||
.email-odysseus-attach-empty {
|
||||
padding: 14px 8px;
|
||||
color: var(--muted, #888);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-odysseus-attach-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 7px;
|
||||
padding-top: 7px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||
background: var(--bg);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
.email-odysseus-attach-count {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--muted, #888);
|
||||
font-size: 11px;
|
||||
}
|
||||
.email-odysseus-attach-selected {
|
||||
height: 28px;
|
||||
min-width: 72px;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
|
||||
background: var(--accent-primary, var(--red));
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.email-odysseus-attach-selected svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.email-odysseus-attach-selected:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.email-odysseus-attach-selected.is-loading {
|
||||
opacity: 0.72;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.email-odysseus-attach-menu {
|
||||
left: 8px !important;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
top: auto !important;
|
||||
width: auto;
|
||||
max-height: 58vh;
|
||||
}
|
||||
.email-odysseus-attach-list {
|
||||
max-height: calc(58vh - 88px);
|
||||
}
|
||||
}
|
||||
|
||||
.email-cc-toggle {
|
||||
background: none; border: none; color: var(--fg);
|
||||
opacity: 0.4; font-size: 11px; cursor: pointer;
|
||||
@@ -32313,8 +33024,12 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.contact-suggestion:last-child { border-bottom: none; }
|
||||
.contact-suggestion:hover, .contact-suggestion.active {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
.contact-suggestion:hover,
|
||||
.contact-suggestion.active,
|
||||
.contact-suggestion[aria-selected="true"] {
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--accent, var(--red))) 15%, transparent);
|
||||
outline: 1px solid color-mix(in srgb, var(--accent-primary, var(--accent, var(--red))) 40%, transparent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.contact-suggestion .contact-name { font-weight: 600; color: var(--fg); }
|
||||
.contact-suggestion .contact-email { opacity: 0.6; font-size: 11px; }
|
||||
@@ -36135,7 +36850,15 @@ button.cal-add-btn.cal-add-btn-text {
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
/* Settings "+ Add server" matches the model-dir "+ Add" path button (22px). */
|
||||
#cookbook-server-add.cal-add-btn-text { height: 21px; border-radius: 11px; position: relative; top: 3px; }
|
||||
#cookbook-server-add.cal-add-btn-text {
|
||||
height: 28px;
|
||||
min-width: 62px;
|
||||
border-radius: 14px;
|
||||
padding: 0 12px 0 8px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
font-size: 12px;
|
||||
}
|
||||
button.cal-add-btn.cal-add-btn-text:hover {
|
||||
background: color-mix(in srgb, var(--fg) 14%, transparent);
|
||||
border-color: var(--accent);
|
||||
@@ -39086,6 +39809,26 @@ body.theme-frosted .modal {
|
||||
/* Nudge the Delete button 4px left. */
|
||||
.research-job-action[data-action="delete"] { position: relative; right: 2px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Mobile: the "Delete" label is too wide in Deep Research job rows; keep the
|
||||
familiar trash action but make it icon-only. */
|
||||
.research-job-action[data-action="delete"] {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
font-size: 0;
|
||||
}
|
||||
.research-job-action[data-action="delete"] svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* "Standard" (uncategorized) research uses green everywhere — set its --cat-color
|
||||
to the success green so the Visual Report button matches the green badge. */
|
||||
.research-job-card.done:not([data-category]) { --cat-color: var(--color-success); }
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v336';
|
||||
const CACHE_NAME = 'odysseus-v344';
|
||||
|
||||
// Core shell precached on install so repeat opens are instant without any
|
||||
// network wait. Keep this list in sync with the <script type="module"> tags
|
||||
|
||||
@@ -50,6 +50,14 @@ AREAS: tuple[str, ...] = (
|
||||
# Backward-compatible aggregate selectors for focused runs whose original
|
||||
# monolithic files were split into more specific taxonomy sub-areas.
|
||||
SUB_AREA_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
"service_health": (
|
||||
"service_health_chromadb",
|
||||
"service_health_search",
|
||||
"service_health_ntfy",
|
||||
"service_health_email",
|
||||
"service_health_providers",
|
||||
"service_health_collect",
|
||||
),
|
||||
"embedding": ("embedding", "embedding_memory"),
|
||||
}
|
||||
|
||||
@@ -214,6 +222,7 @@ def build_parser(
|
||||
"""Build the argument parser for the focused runner."""
|
||||
if valid_sub_areas is None:
|
||||
valid_sub_areas = discover_sub_areas()
|
||||
valid_sub_areas = frozenset(valid_sub_areas) | frozenset(SUB_AREA_ALIASES)
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="run_focus.py",
|
||||
description=(
|
||||
|
||||
@@ -334,16 +334,11 @@ def test_pop_notifications_owner_filtered():
|
||||
def test_admin_only_actions_set_contains_shell_runners():
|
||||
"""The constant defining shell-executing action types must include
|
||||
the three risky entries. Catches accidental removal."""
|
||||
from routes import task_routes
|
||||
# `_ADMIN_ONLY_ACTIONS` is a closure constant. Easiest pin: re-read
|
||||
# the source and check for the three risky entries + the admin gate
|
||||
# wording.
|
||||
src = open(task_routes.__file__, encoding="utf-8").read()
|
||||
assert '"run_local"' in src
|
||||
assert '"run_script"' in src
|
||||
assert '"ssh_command"' in src
|
||||
# And the gate is wired into both create and update paths.
|
||||
assert "Action '" in src and "requires admin privileges" in src
|
||||
from src.task_action_policy import ADMIN_ONLY_TASK_ACTIONS
|
||||
|
||||
assert "run_local" in ADMIN_ONLY_TASK_ACTIONS
|
||||
assert "run_script" in ADMIN_ONLY_TASK_ACTIONS
|
||||
assert "ssh_command" in ADMIN_ONLY_TASK_ACTIONS
|
||||
|
||||
|
||||
def test_task_create_notification_default_allows_action_specific_defaults():
|
||||
|
||||
@@ -78,3 +78,29 @@ async def test_list_events_honors_range_aliases(start_key, end_key):
|
||||
summaries = [event["summary"] for event in res["events"]]
|
||||
assert summaries == ["Late June planning"]
|
||||
assert "between 2126-06-01 and 2126-07-01" in res["response"]
|
||||
|
||||
async def test_list_events_rejects_partial_loose_range():
|
||||
from src.tool_implementations import do_manage_calendar
|
||||
|
||||
owner = "calendar-partial-" + uuid.uuid4().hex[:8]
|
||||
|
||||
# Partial: has query and start, but no end
|
||||
res = await do_manage_calendar(json.dumps({
|
||||
"action": "list_events",
|
||||
"query": "July",
|
||||
"start_time": "2126-07-01T00:00:00Z",
|
||||
}), owner=owner)
|
||||
|
||||
assert res.get("exit_code", 1) == 1, res
|
||||
assert "list_events needs explicit start/end" in res.get("error", "")
|
||||
|
||||
# Partial: has query and end, but no start
|
||||
res2 = await do_manage_calendar(json.dumps({
|
||||
"action": "list_events",
|
||||
"query": "July",
|
||||
"end_time": "2126-07-31T23:59:59Z",
|
||||
}), owner=owner)
|
||||
|
||||
assert res2.get("exit_code", 1) == 1, res2
|
||||
assert "list_events needs explicit start/end" in res2.get("error", "")
|
||||
|
||||
|
||||
@@ -78,3 +78,36 @@ async def test_update_event_dtstart_anchored_to_user_tz(tokyo_offset):
|
||||
assert bool(ev.is_utc) is True
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def test_list_events_accepts_start_time_end_time_aliases():
|
||||
from src.tool_implementations import do_manage_calendar
|
||||
|
||||
owner = "list-" + uuid.uuid4().hex[:6]
|
||||
created = await do_manage_calendar(json.dumps({
|
||||
"action": "create_event",
|
||||
"summary": "July planning",
|
||||
"dtstart": "2026-07-15T12:00:00Z",
|
||||
"dtend": "2026-07-15T13:00:00Z",
|
||||
}), owner=owner)
|
||||
assert created.get("exit_code", 0) == 0, created
|
||||
|
||||
listed = await do_manage_calendar(json.dumps({
|
||||
"action": "list_events",
|
||||
"start_time": "2026-07-01T00:00:00Z",
|
||||
"end_time": "2026-08-01T00:00:00Z",
|
||||
}), owner=owner)
|
||||
assert listed.get("exit_code", 0) == 0, listed
|
||||
assert "between 2026-07-01 and 2026-08-01" in listed["response"]
|
||||
assert [event["summary"] for event in listed["events"]] == ["July planning"]
|
||||
|
||||
|
||||
async def test_list_events_query_without_range_does_not_default_to_two_weeks():
|
||||
from src.tool_implementations import do_manage_calendar
|
||||
|
||||
listed = await do_manage_calendar(json.dumps({
|
||||
"action": "list_events",
|
||||
"query": "July",
|
||||
}), owner="list-" + uuid.uuid4().hex[:6])
|
||||
assert listed.get("exit_code") == 1, listed
|
||||
assert "explicit start/end" in listed["error"]
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Issue #3229 — allow_bash / allow_web_search must work for JSON API callers
|
||||
and admin users must get bash enabled by default.
|
||||
"""Issue #3229 and explicit web-toggle regressions.
|
||||
|
||||
Bug: allow_bash and allow_web_search were only read from form_data, so JSON
|
||||
API callers (Content-Type: application/json) always had bash disabled.
|
||||
|
||||
Fix: (1) Read from JSON body as fallback.
|
||||
(2) Only add bash/web_search to disabled_tools when explicitly set to a
|
||||
falsy value; when unset (None), defer to per-user privilege checks.
|
||||
(2) Keep bash on the privilege fallback when unset.
|
||||
(3) Require an explicit per-turn web setting before exposing web tools.
|
||||
"""
|
||||
|
||||
import ast
|
||||
@@ -14,6 +13,13 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.action_intents import classify_tool_intent
|
||||
from src.tool_policy import (
|
||||
WEB_TOOL_NAMES,
|
||||
is_web_search_explicitly_denied,
|
||||
web_search_enabled_for_turn,
|
||||
)
|
||||
|
||||
_CHAT_ROUTES = Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py"
|
||||
|
||||
|
||||
@@ -73,9 +79,8 @@ def test_allow_web_search_reads_from_body_as_fallback():
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_tools_does_not_bash_when_allow_bash_is_none():
|
||||
"""When allow_bash is not set (None), bash must NOT be unconditionally
|
||||
added to disabled_tools. The per-user privilege check handles it.
|
||||
def test_disabled_tools_respects_missing_vs_explicit_toggles():
|
||||
"""Bash still defers to privileges, but web is an explicit per-turn opt-in.
|
||||
"""
|
||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
@@ -86,11 +91,14 @@ def test_disabled_tools_does_not_bash_when_allow_bash_is_none():
|
||||
assert "allow_bash is not None" in source, (
|
||||
"disabled_tools check must guard against allow_bash being None"
|
||||
)
|
||||
assert "allow_web_search is not None" in source, (
|
||||
"disabled_tools check must guard against allow_web_search being None"
|
||||
assert "web_search_enabled_for_turn(allow_web_search, use_web)" in source, (
|
||||
"web tools must be gated through the explicit per-turn web setting"
|
||||
)
|
||||
assert "_explicit_web_intent" in source and "not _explicit_web_intent" in source, (
|
||||
"explicit web-search requests must override an off web toggle for that turn"
|
||||
assert "disabled_tools.update(WEB_TOOL_NAMES)" in source, (
|
||||
"disabled_tools must add web_search/web_fetch when web is not explicitly enabled"
|
||||
)
|
||||
assert "_forced_tools = set(WEB_TOOL_NAMES)" in source, (
|
||||
"web tools should only be forced visible from the explicit web setting"
|
||||
)
|
||||
|
||||
|
||||
@@ -100,9 +108,11 @@ def test_disabled_tools_does_not_bash_when_allow_bash_is_none():
|
||||
def _build_disabled_tools(
|
||||
allow_bash=None,
|
||||
allow_web_search=None,
|
||||
use_web=None,
|
||||
can_use_bash=True,
|
||||
can_use_browser=True,
|
||||
explicit_web_intent=False,
|
||||
global_disabled=None,
|
||||
):
|
||||
"""Replicate the disabled-tools logic from chat_stream for unit testing.
|
||||
|
||||
@@ -110,22 +120,36 @@ def _build_disabled_tools(
|
||||
"""
|
||||
disabled_tools = set()
|
||||
|
||||
# Issue #3229 fix: only disable when explicitly set to a falsy value.
|
||||
# Issue #3229 fix: only disable bash when explicitly set to a falsy value.
|
||||
if allow_bash is not None and str(allow_bash).lower() != "true":
|
||||
disabled_tools.add("bash")
|
||||
if (
|
||||
allow_web_search is not None
|
||||
and str(allow_web_search).lower() != "true"
|
||||
and not explicit_web_intent
|
||||
):
|
||||
disabled_tools.add("web_search")
|
||||
disabled_tools.add("web_fetch")
|
||||
search_enabled = web_search_enabled_for_turn(allow_web_search, use_web)
|
||||
if is_web_search_explicitly_denied(allow_web_search) or not search_enabled:
|
||||
disabled_tools.update(WEB_TOOL_NAMES)
|
||||
if explicit_web_intent:
|
||||
disabled_tools.update({
|
||||
"bash", "python",
|
||||
"search_chats", "manage_skills", "manage_memory",
|
||||
"read_file", "write_file", "edit_file",
|
||||
"create_document", "edit_document", "update_document",
|
||||
"send_email", "reply_to_email",
|
||||
"manage_notes", "manage_calendar", "manage_tasks",
|
||||
"api_call", "builtin_browser",
|
||||
})
|
||||
if search_enabled:
|
||||
disabled_tools.difference_update(WEB_TOOL_NAMES)
|
||||
else:
|
||||
disabled_tools.update(WEB_TOOL_NAMES)
|
||||
elif search_enabled:
|
||||
disabled_tools.difference_update(WEB_TOOL_NAMES)
|
||||
|
||||
# Enforce per-user privileges
|
||||
if not can_use_bash:
|
||||
disabled_tools.update({"bash", "python", "read_file", "write_file"})
|
||||
if not can_use_browser:
|
||||
disabled_tools.add("builtin_browser")
|
||||
if global_disabled and isinstance(global_disabled, list):
|
||||
disabled_tools.update(global_disabled)
|
||||
|
||||
return disabled_tools
|
||||
|
||||
@@ -156,15 +180,56 @@ def test_json_body_allow_web_search_false_disables_web():
|
||||
assert "web_fetch" in disabled
|
||||
|
||||
|
||||
def test_explicit_web_intent_overrides_false_web_toggle_for_turn():
|
||||
"""A stale/off web toggle must not remove web tools when the message
|
||||
explicitly asks to use web search."""
|
||||
def test_chat_mode_use_web_true_enables_web():
|
||||
"""Chat pre-search sends use_web=true as the explicit web setting."""
|
||||
disabled = _build_disabled_tools(use_web="true")
|
||||
assert "web_search" not in disabled
|
||||
assert "web_fetch" not in disabled
|
||||
|
||||
|
||||
def test_allow_web_search_false_wins_over_use_web_true():
|
||||
"""The agent web toggle hard-denies web even if another path says use_web=true."""
|
||||
disabled = _build_disabled_tools(allow_web_search="false", use_web="true")
|
||||
assert "web_search" in disabled
|
||||
assert "web_fetch" in disabled
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
"please use web search for current CVEs",
|
||||
"search the web for current CVEs",
|
||||
"can you look up the latest docs",
|
||||
],
|
||||
)
|
||||
def test_explicit_false_disables_web_despite_prompt_web_intent(message):
|
||||
"""Explicit allow_web_search=false is a hard deny even when the prompt
|
||||
asks for web search."""
|
||||
intent = classify_tool_intent(message)
|
||||
assert intent is not None
|
||||
assert intent.category == "web"
|
||||
|
||||
disabled = _build_disabled_tools(
|
||||
allow_web_search="false",
|
||||
explicit_web_intent=True,
|
||||
)
|
||||
assert "web_search" not in disabled
|
||||
assert "web_fetch" not in disabled
|
||||
assert "web_search" in disabled
|
||||
assert "web_fetch" in disabled
|
||||
|
||||
|
||||
def test_prompt_web_intent_does_not_enable_web_without_setting():
|
||||
"""Prompt-derived web intent alone must not expose web tools."""
|
||||
intent = classify_tool_intent("look up the latest docs")
|
||||
assert intent is not None
|
||||
assert intent.category == "web"
|
||||
|
||||
disabled = _build_disabled_tools(
|
||||
allow_web_search=None,
|
||||
use_web=None,
|
||||
explicit_web_intent=True,
|
||||
)
|
||||
assert "web_search" in disabled
|
||||
assert "web_fetch" in disabled
|
||||
|
||||
|
||||
def test_admin_user_gets_bash_enabled_by_default():
|
||||
@@ -175,13 +240,11 @@ def test_admin_user_gets_bash_enabled_by_default():
|
||||
assert "bash" not in disabled
|
||||
|
||||
|
||||
def test_admin_user_gets_web_search_enabled_by_default():
|
||||
"""When allow_web_search is not set and user has normal privileges,
|
||||
web_search must NOT be disabled.
|
||||
"""
|
||||
def test_web_search_disabled_by_default_without_explicit_turn_setting():
|
||||
"""Missing web settings must not expose web tools by default."""
|
||||
disabled = _build_disabled_tools(allow_web_search=None)
|
||||
assert "web_search" not in disabled
|
||||
assert "web_fetch" not in disabled
|
||||
assert "web_search" in disabled
|
||||
assert "web_fetch" in disabled
|
||||
|
||||
|
||||
def test_non_privileged_user_without_explicit_flag_still_disabled():
|
||||
@@ -200,6 +263,16 @@ def test_non_privileged_user_explicit_true_overridden_by_privilege():
|
||||
assert "bash" in disabled
|
||||
|
||||
|
||||
def test_global_disabled_web_wins_over_explicit_web_enable():
|
||||
"""Admin-level disabled tools are still a hard deny."""
|
||||
disabled = _build_disabled_tools(
|
||||
allow_web_search="true",
|
||||
global_disabled=["web_search", "web_fetch"],
|
||||
)
|
||||
assert "web_search" in disabled
|
||||
assert "web_fetch" in disabled
|
||||
|
||||
|
||||
def test_form_data_none_body_true_works():
|
||||
"""Simulates: form_data has no allow_bash, body has allow_bash=true.
|
||||
After the fallback (`form_data.get(...) or body.get(...)`), allow_bash
|
||||
@@ -241,6 +314,6 @@ def test_frontend_always_sends_explicit_allow_bash():
|
||||
def test_frontend_sends_explicit_allow_web_search_false_in_agent_mode():
|
||||
"""chat.js must send allow_web_search=false when web toggle is off in agent mode."""
|
||||
source = _CHAT_JS.read_text(encoding="utf-8")
|
||||
assert "allow_web_search', 'false'" in source, (
|
||||
assert "fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false')" in source, (
|
||||
"Frontend must send explicit allow_web_search=false in agent mode when toggle is off"
|
||||
)
|
||||
|
||||
@@ -91,6 +91,30 @@ def test_grep_python_fallback_when_no_rg(repo, monkeypatch):
|
||||
assert ".git/config" not in r["output"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("rg") is None, reason="targets the ripgrep fast-path")
|
||||
def test_grep_skips_case_variant_sensitive_files_rg(repo):
|
||||
"""The rg fast-path must exclude deny-listed key files case-insensitively.
|
||||
|
||||
A file whose name is a case variant of a sensitive pattern (e.g. ID_RSA vs
|
||||
id_rsa, Known_Hosts vs known_hosts) points at the same secret on a
|
||||
case-insensitive filesystem, so grep must not return its contents. The
|
||||
Python fallback already folds case via _is_sensitive_path; a plain --glob
|
||||
exclusion is case-sensitive, so it would leak these — this pins the rg path.
|
||||
"""
|
||||
token = "GREPSECRET_TOKEN_ZZZ"
|
||||
with open(os.path.join(repo, "notes.txt"), "w") as f:
|
||||
f.write(f"see {token}\n")
|
||||
with open(os.path.join(repo, "ID_RSA"), "w") as f:
|
||||
f.write(f"PRIVATE {token}\n")
|
||||
with open(os.path.join(repo, "Known_Hosts"), "w") as f:
|
||||
f.write(f"host {token}\n")
|
||||
r = _run("grep", f'{{"pattern": "{token}", "path": "{repo}"}}')
|
||||
assert r["exit_code"] == 0
|
||||
assert "notes.txt" in r["output"] # ordinary matches still returned
|
||||
assert "ID_RSA" not in r["output"] # case-variant key excluded
|
||||
assert "Known_Hosts" not in r["output"]
|
||||
|
||||
|
||||
# ── glob ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_glob_py(repo):
|
||||
|
||||
@@ -24,7 +24,11 @@ def _add_handler():
|
||||
def _stub_store(monkeypatch):
|
||||
created = []
|
||||
monkeypatch.setattr(cr, "_fetch_contacts", lambda *a, **k: [])
|
||||
monkeypatch.setattr(cr, "_create_contact", lambda name, email: created.append((name, email)) or True)
|
||||
monkeypatch.setattr(
|
||||
cr,
|
||||
"_create_contact",
|
||||
lambda name, email="", address="", phones=None: created.append((name, email, address, phones or [])) or True,
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
@@ -33,10 +37,18 @@ def test_null_name_does_not_crash(_stub_store):
|
||||
result = asyncio.run(handler({"name": None, "email": "x@y.com"}, _admin="admin"))
|
||||
assert result["success"] is True
|
||||
# name fell back to the email local-part instead of crashing.
|
||||
assert _stub_store == [("x", "x@y.com")]
|
||||
assert _stub_store == [("x", "x@y.com", "", [])]
|
||||
|
||||
|
||||
def test_null_email_is_rejected_cleanly(_stub_store):
|
||||
def test_null_email_does_not_crash(_stub_store):
|
||||
handler = _add_handler()
|
||||
result = asyncio.run(handler({"name": "Bob", "email": None}, _admin="admin"))
|
||||
assert result == {"success": False, "error": "Email required"}
|
||||
assert result["success"] is True
|
||||
assert _stub_store == [("Bob", "", "", [])]
|
||||
|
||||
|
||||
def test_phone_only_contact_is_allowed(_stub_store):
|
||||
handler = _add_handler()
|
||||
result = asyncio.run(handler({"name": "Bob", "email": None, "phone": "0805412 7841"}, _admin="admin"))
|
||||
assert result["success"] is True
|
||||
assert _stub_store == [("Bob", "", "", ["0805412 7841"])]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Regression test for the contacts route shim (slice 2e, #4082/#4071).
|
||||
|
||||
The backward-compat shim at ``routes/contacts_routes.py`` uses ``sys.modules``
|
||||
replacement so the legacy import path and the canonical ``routes.contacts.*``
|
||||
path resolve to the *same* module object. This is required because:
|
||||
|
||||
* ``test_carddav_password_encryption.py`` uses string-targeted
|
||||
``monkeypatch.setattr("routes.contacts_routes.SETTINGS_FILE", ...)`` which
|
||||
must reach the canonical module to take effect;
|
||||
* ``test_contacts_add_null_name.py`` / ``test_contacts_carddav_security.py``
|
||||
use ``import routes.contacts_routes as cr`` + ``setattr(cr, ...)``;
|
||||
* the module owns mutable state (``_contact_cache``) that must be shared
|
||||
across import paths.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import routes.contacts_routes as _shim_contacts # noqa: F401
|
||||
|
||||
|
||||
def test_legacy_and_canonical_contacts_module_are_same_object():
|
||||
"""``import routes.contacts_routes`` must alias the canonical module."""
|
||||
legacy = importlib.import_module("routes.contacts_routes")
|
||||
canonical = importlib.import_module("routes.contacts.contacts_routes")
|
||||
assert legacy is canonical, (
|
||||
"routes.contacts_routes shim must resolve to the canonical "
|
||||
"routes.contacts.contacts_routes module object"
|
||||
)
|
||||
|
||||
|
||||
def test_string_targeted_monkeypatch_reaches_canonical(monkeypatch):
|
||||
"""String-targeted ``monkeypatch.setattr`` via the legacy path must reach
|
||||
the canonical module.
|
||||
|
||||
``test_carddav_password_encryption.py`` patches
|
||||
``"routes.contacts_routes.SETTINGS_FILE"`` as a fixture setup; for that
|
||||
to take effect at runtime, the legacy module name and the canonical
|
||||
module must be identical.
|
||||
"""
|
||||
canonical = importlib.import_module("routes.contacts.contacts_routes")
|
||||
|
||||
sentinel = object()
|
||||
monkeypatch.setattr("routes.contacts_routes.setup_contacts_routes", sentinel)
|
||||
assert canonical.setup_contacts_routes is sentinel, (
|
||||
"string-targeted monkeypatch via legacy path did not reach the canonical module"
|
||||
)
|
||||
@@ -20,7 +20,9 @@ def test_background_status_poll_reconciles_into_local_tasks():
|
||||
source = _read("static/js/cookbookRunning.js")
|
||||
|
||||
assert "const statusById = new Map(tasks.map(t => [t.session_id, t]));" in source
|
||||
assert "const nextStatus = live.status === 'completed'" in source
|
||||
assert "const completedByOutput = depDone || downloadDone;" in source
|
||||
assert "const nextStatus = completedByOutput" in source
|
||||
assert "live.status === 'completed'" in source
|
||||
assert "? 'done'" in source
|
||||
assert ": (live.status === 'error'" in source
|
||||
assert "? 'error'" in source
|
||||
@@ -75,7 +77,8 @@ def test_background_poll_recovers_done_for_stopped_dependency_install():
|
||||
downgrading the card to crashed."""
|
||||
source = _read("static/js/cookbookRunning.js")
|
||||
|
||||
assert "const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);" in source
|
||||
assert "const combinedOutput = `${task.output || ''}\\n${live.output_tail || ''}`;" in source
|
||||
assert "const depDone = !!task.payload?._dep && _depInstallSucceeded(combinedOutput);" in source
|
||||
assert "(depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped')" in source
|
||||
|
||||
|
||||
@@ -91,14 +94,14 @@ def test_background_poll_recovers_done_for_completed_download():
|
||||
normalized = " ".join(source.split())
|
||||
assert (
|
||||
"const downloadDone = task.type === 'download' "
|
||||
"&& String(task.output || '').includes('DOWNLOAD_OK');"
|
||||
"&& String(combinedOutput || '').includes('DOWNLOAD_OK');"
|
||||
) in normalized
|
||||
|
||||
|
||||
def test_dependency_install_payload_keeps_env_path_for_refresh():
|
||||
source = _read("static/js/cookbook.js")
|
||||
|
||||
assert "env_path: _envState.envPath || ''" in source
|
||||
assert "env_path: targetEnvPath || ''" in source
|
||||
|
||||
|
||||
def test_local_dependency_probe_refreshes_user_site_visibility():
|
||||
|
||||
@@ -29,7 +29,7 @@ def test_diagnose_sglang_native_dependency_errors():
|
||||
diagnosis = _diagnose_serve_output(output)
|
||||
|
||||
assert diagnosis is not None
|
||||
assert "SGLang native dependencies" in diagnosis["message"]
|
||||
assert "SGLang native kernel/runtime" in diagnosis["message"]
|
||||
labels = [suggestion["label"] for suggestion in diagnosis["suggestions"]]
|
||||
assert any("libnuma-dev" in label for label in labels)
|
||||
assert any("python3.12-dev" in label for label in labels)
|
||||
|
||||
@@ -17,6 +17,6 @@ def test_sglang_native_dependency_diagnosis_is_exposed_to_browser():
|
||||
|
||||
assert r"Python\.h" in source
|
||||
assert r"libnuma\.so\.1" in source
|
||||
assert "SGLang native dependencies" in source
|
||||
assert "SGLang native kernel/runtime" in source
|
||||
assert "libnuma-dev python3.12-dev build-essential" in source
|
||||
assert "sglang-kernel" in source
|
||||
|
||||
@@ -31,8 +31,8 @@ def test_selected_server_helpers_prefer_profile_key_before_host_fallback():
|
||||
|
||||
def test_cookbook_submodules_resolve_visible_profile_selection():
|
||||
assert "_serverByVal?.(_ssv)" in DOWNLOAD
|
||||
assert "_serverByVal?.(_envState.remoteServerKey || host)" in DOWNLOAD
|
||||
assert "_serverByVal?.(_envState.remoteServerKey || _zh)" in DOWNLOAD
|
||||
assert "_serverByVal?.(_envState.remoteServerKey || _envState.remoteHost || '')" in DOWNLOAD
|
||||
assert "_serverByVal?.(zombieCandidate.remoteServerKey || zombieCandidate.payload?.remote_server_key || _zh)" in DOWNLOAD
|
||||
assert "_serverByVal(_envState.remoteServerKey || remoteHost)" in HWFIT
|
||||
assert "hk: _currentServerValue()" in HWFIT
|
||||
assert "sel.value = _currentServerValue();" in HWFIT
|
||||
|
||||
@@ -89,6 +89,18 @@ def test_request_flags_vision():
|
||||
assert vision is True
|
||||
|
||||
|
||||
def test_request_flags_non_dict_last_message_does_not_crash():
|
||||
# A client can send a bare-string (non-dict) last element; before the
|
||||
# isinstance guard this raised AttributeError on last.get("role").
|
||||
assert copilot.request_flags(["hi"]) == (False, False)
|
||||
assert copilot.request_flags([{"role": "user"}, "trailing"]) == (False, False)
|
||||
|
||||
|
||||
def test_request_flags_empty_and_none():
|
||||
assert copilot.request_flags([]) == (False, False)
|
||||
assert copilot.request_flags(None) == (False, False)
|
||||
|
||||
|
||||
def test_apply_request_headers_mutates():
|
||||
h = {"X-GitHub-Api-Version": "v"}
|
||||
copilot.apply_request_headers(h, [{"role": "tool", "content": "x"}])
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Regression: extensionless image/audio uploads must get a valid MIME subtype.
|
||||
|
||||
The data-URL subtype was derived only from the stored file's extension
|
||||
(`image_format = ext[1:]`). A pasted screenshot or any file whose stored id
|
||||
carries no extension yields `ext == ""`, so the emitted URL was
|
||||
`data:image/;base64,...` — an empty MIME subtype (invalid per RFC 2046) that
|
||||
vision/audio endpoints reject, silently dropping the attachment. When the
|
||||
extension is missing, fall back to the resolved MIME subtype. Extensions that
|
||||
are present are unchanged.
|
||||
"""
|
||||
|
||||
|
||||
class _Handler:
|
||||
def __init__(self, uploads, image=False, audio=False):
|
||||
self.uploads = uploads
|
||||
self._image = image
|
||||
self._audio = audio
|
||||
|
||||
def resolve_upload(self, fid, owner=None):
|
||||
return self.uploads.get(fid)
|
||||
|
||||
def _inside_upload_dir(self, path):
|
||||
return True
|
||||
|
||||
def is_image_file(self, name, mime):
|
||||
return self._image and (mime or "").startswith("image/")
|
||||
|
||||
def is_audio_file(self, name, mime):
|
||||
return self._audio and (mime or "").startswith("audio/")
|
||||
|
||||
def is_document_file(self, name, mime):
|
||||
return False
|
||||
|
||||
|
||||
def _blocks(content, block_type):
|
||||
return [b for b in content if isinstance(b, dict) and b.get("type") == block_type]
|
||||
|
||||
|
||||
def test_extensionless_image_uses_mime_subtype(tmp_path):
|
||||
import src.document_processor as dp
|
||||
|
||||
p = tmp_path / ("a" * 32) # bare id, no extension
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\nfake")
|
||||
uploads = {"img": {"path": str(p), "name": "screenshot", "mime": "image/png"}}
|
||||
|
||||
content = dp.build_user_content("look", ["img"], str(tmp_path), _Handler(uploads, image=True), owner="t")
|
||||
imgs = _blocks(content, "image_url")
|
||||
assert imgs, content
|
||||
assert imgs[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_extensionless_audio_uses_mime_subtype(tmp_path):
|
||||
import src.document_processor as dp
|
||||
|
||||
p = tmp_path / ("b" * 32)
|
||||
p.write_bytes(b"fakeaudio")
|
||||
uploads = {"aud": {"path": str(p), "name": "recording", "mime": "audio/mpeg"}}
|
||||
|
||||
content = dp.build_user_content("listen", ["aud"], str(tmp_path), _Handler(uploads, audio=True), owner="t")
|
||||
auds = _blocks(content, "audio")
|
||||
assert auds, content
|
||||
assert auds[0]["audio"]["url"].startswith("data:audio/mpeg;base64,")
|
||||
|
||||
|
||||
def test_extension_present_is_unchanged(tmp_path):
|
||||
import src.document_processor as dp
|
||||
|
||||
p = tmp_path / "pic.png"
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
uploads = {"img": {"path": str(p), "name": "pic.png", "mime": "image/png"}}
|
||||
|
||||
content = dp.build_user_content("look", ["img"], str(tmp_path), _Handler(uploads, image=True), owner="t")
|
||||
imgs = _blocks(content, "image_url")
|
||||
assert imgs[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Cross-tenant access control for legacy owner-less email accounts.
|
||||
|
||||
`email_accounts` is the one owner-scoped table left out of the legacy-owner
|
||||
migration backfill (core/database.py), so rows with owner NULL/"" persist on a
|
||||
multi-user deploy — e.g. an account configured while auth was disabled, or an
|
||||
imported legacy row. The HTTP route guards (`_assert_owns_account` and the
|
||||
explicit-account_id path in `_get_email_config`) must scope such rows to a
|
||||
mailbox match, exactly like the `_owner_or_matching_legacy_account` fallback and
|
||||
the MCP `_account_visible_to_owner` gate. Otherwise any authenticated user can
|
||||
read/send/update-credentials/delete another tenant's imported mailbox.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _make_db():
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from core.database import Base
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
Factory = sessionmaker(bind=engine)
|
||||
return Factory
|
||||
|
||||
|
||||
def _make_account(Factory, account_id, owner, imap_user, from_address="", is_default=False):
|
||||
from core.database import EmailAccount
|
||||
db = Factory()
|
||||
row = EmailAccount(
|
||||
id=account_id,
|
||||
owner=owner,
|
||||
name="Test",
|
||||
enabled=True,
|
||||
is_default=is_default,
|
||||
imap_host="imap.example.com",
|
||||
imap_port=993,
|
||||
imap_user=imap_user,
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
smtp_user=imap_user,
|
||||
from_address=from_address or imap_user,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_assert_owns_account_rejects_ownerless_account_for_other_tenant():
|
||||
"""The core regression: a legacy owner-less mailbox is NOT accessible to an
|
||||
authenticated caller whose own mailbox does not match it."""
|
||||
from routes.email_helpers import _assert_owns_account
|
||||
Factory = _make_db()
|
||||
# owner="" (created while auth was disabled); mailbox belongs to victim.
|
||||
_make_account(Factory, "acct-legacy", owner="", imap_user="victim@corp.com")
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_assert_owns_account("acct-legacy", "attacker")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_assert_owns_account_allows_owned_account():
|
||||
from routes.email_helpers import _assert_owns_account
|
||||
Factory = _make_db()
|
||||
_make_account(Factory, "acct-bob", owner="bob", imap_user="bob@corp.com")
|
||||
with mock.patch("core.database.SessionLocal", Factory):
|
||||
_assert_owns_account("acct-bob", "bob") # no raise
|
||||
|
||||
|
||||
def test_assert_owns_account_allows_ownerless_account_on_mailbox_match():
|
||||
"""Legacy-claim path stays intact: the user whose mailbox matches an
|
||||
owner-less account may still act on it (imap_user or from_address)."""
|
||||
from routes.email_helpers import _assert_owns_account
|
||||
Factory = _make_db()
|
||||
_make_account(Factory, "acct-legacy", owner="", imap_user="alice@corp.com")
|
||||
with mock.patch("core.database.SessionLocal", Factory):
|
||||
_assert_owns_account("acct-legacy", "alice@corp.com") # no raise
|
||||
|
||||
|
||||
def test_assert_owns_account_noop_for_single_user_mode():
|
||||
"""owner == "" (unconfigured / single-user) accepts any account, unchanged."""
|
||||
from routes.email_helpers import _assert_owns_account
|
||||
Factory = _make_db()
|
||||
_make_account(Factory, "acct-legacy", owner="", imap_user="whoever@corp.com")
|
||||
with mock.patch("core.database.SessionLocal", Factory):
|
||||
_assert_owns_account("acct-legacy", "") # no raise
|
||||
|
||||
|
||||
def test_get_email_config_does_not_resolve_ownerless_account_for_other_tenant(monkeypatch):
|
||||
"""`_get_email_config(account_id=..., owner=...)` must not serve an
|
||||
owner-less account (and its decrypted creds) to a non-matching tenant."""
|
||||
import routes.email_helpers as eh
|
||||
Factory = _make_db()
|
||||
_make_account(Factory, "acct-legacy", owner="", imap_user="victim@corp.com", is_default=True)
|
||||
|
||||
# Make the settings.json / env fallback empty and deterministic.
|
||||
monkeypatch.setattr(eh, "_load_settings", lambda: {}, raising=False)
|
||||
for var in ("IMAP_HOST", "SMTP_HOST", "IMAP_USER", "SMTP_USER"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory):
|
||||
cfg = eh._get_email_config(account_id="acct-legacy", owner="attacker")
|
||||
|
||||
assert cfg.get("account_id") != "acct-legacy"
|
||||
|
||||
|
||||
def test_get_email_config_resolves_ownerless_account_on_mailbox_match():
|
||||
"""The mailbox owner still resolves their claimable legacy account by id."""
|
||||
import routes.email_helpers as eh
|
||||
Factory = _make_db()
|
||||
_make_account(Factory, "acct-legacy", owner="", imap_user="alice@corp.com")
|
||||
with mock.patch("core.database.SessionLocal", Factory):
|
||||
cfg = eh._get_email_config(account_id="acct-legacy", owner="alice@corp.com")
|
||||
assert cfg.get("account_id") == "acct-legacy"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user