mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-16 13:08:02 +00:00
Compare commits
187 Commits
df9c20e6c2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9844a2f9a1 | |||
| 724305b65d | |||
| 543534924a | |||
| 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 | |||
| ff7164b9ec | |||
| 7f43678a24 | |||
| 5f6e6a2c4a | |||
| cf85c42195 | |||
| 79716d717a | |||
| d360401808 | |||
| c89258e4a6 | |||
| 5777cf2d00 | |||
| 0fc98c4a17 | |||
| 9718f7874b | |||
| 8c943226f8 | |||
| 0dc98ec9b9 | |||
| 5e9b415bd9 | |||
| b1f9f67d9d | |||
| 88191d17fb | |||
| dff91efb10 | |||
| b26ebbda95 | |||
| 260f432332 | |||
| e157f1e63d | |||
| 265f0911d5 | |||
| dc3530b8fa | |||
| 2918739489 | |||
| a07bbeccf5 | |||
| 39eabbb27a | |||
| d2959c1ae8 | |||
| d85afd5d72 | |||
| 7522b02034 | |||
| f38323c3a1 | |||
| 1c1afe5dd1 | |||
| 2412db1583 | |||
| 9a80ab24af | |||
| 005ff73142 | |||
| ba43c73d2a | |||
| 3d75fad52f | |||
| 41420c59fc | |||
| 69b9bb0869 | |||
| d8e76003f1 | |||
| 6127e43113 | |||
| 0cdbdf4186 | |||
| baecea2681 | |||
| 4f387e089a | |||
| b5ea5a1607 | |||
| 7854298eae | |||
| e131245c91 | |||
| 6ae3b6edad | |||
| 838cacf132 | |||
| a72ec0c116 | |||
| b8338b2399 | |||
| 699dbc6ae3 | |||
| 2469d8102d | |||
| ec8ef3ec27 | |||
| f91c0c47e8 | |||
| 80ac782cdc | |||
| c987200616 | |||
| a51f65e9ba | |||
| 24c2c43770 | |||
| a87d0bf2d6 | |||
| 2d9c081ca3 | |||
| 46b127b1f3 | |||
| 19e2326a6f | |||
| ff6fd3eaa7 | |||
| 840e59cd05 | |||
| 783ea99bd0 | |||
| 89119a8cea | |||
| 92cfb2a7cf | |||
| 2a28bb2729 | |||
| a6b6a22de7 | |||
| b712a0a9cb | |||
| 5d5500fbb3 | |||
| ff6359ae81 | |||
| 4a7c03d536 | |||
| e2c8b8eb37 | |||
| b419caf9f7 | |||
| 402a2771b3 | |||
| 3b6d771be9 | |||
| 4d90eb3d44 | |||
| 240768a7a1 | |||
| 1e78ac999d | |||
| 4143bfaa2a | |||
| c0a68acfc8 | |||
| bfeea7f463 | |||
| 6b617f9cad | |||
| 7094c8e285 | |||
| 613a4c059a | |||
| 2cf7f24afc | |||
| 7fa3414308 | |||
| 55661b8925 | |||
| 31dbc6bec6 | |||
| e58d9702b7 | |||
| cbed87e5cb | |||
| ac7cf67ab6 | |||
| 3aa48e9025 | |||
| a6903931f7 | |||
| a32c3f26ab | |||
| 63c07c2188 | |||
| 36ba856607 | |||
| 6bba539d65 | |||
| c95bbd6992 | |||
| ded57e5740 | |||
| 0d84441c1c | |||
| 496544b487 | |||
| de4c338423 | |||
| 36898fba0d | |||
| 2ef44e4bd9 | |||
| c114e7652a | |||
| 5f7de831f9 | |||
| d1ad95c09a | |||
| a5eea9c093 | |||
| f0b3c0bb83 | |||
| 9aed141d96 | |||
| 2cf1e13cab | |||
| fe4aece553 | |||
| e4fed9e85e | |||
| 61cf07eaf1 | |||
| d37abae084 | |||
| 51aff00b13 | |||
| 1b0e0a118b | |||
| ef7e5f90ec | |||
| 36c8a0a3c3 | |||
| 7246e416ac | |||
| d7193e7d0d | |||
| d614675da3 | |||
| 4fa20023b4 | |||
| 1a0f096b9b | |||
| 9eaf5b61b2 | |||
| 3f296e63d2 | |||
| 721261402d | |||
| 0765c60790 | |||
| fc566cf31a | |||
| 5d42e7616a | |||
| 3c80d6adb4 | |||
| f860dffd5e | |||
| 989e083300 | |||
| ca7be0d441 | |||
| fc6a6dc584 | |||
| cc784db006 | |||
| 002eba779e | |||
| bab45142a5 | |||
| 26adeb4d38 | |||
| e0016ade5a | |||
| 079bac1634 | |||
| a256747be9 | |||
| 70e106cdc2 | |||
| 28974ae787 | |||
| 7af3f15288 | |||
| da7c6a667b | |||
| a40371d532 | |||
| be4c4f2926 | |||
| 8180e9cdb1 | |||
| 23b844f113 | |||
| 4222039b67 | |||
| 45ee5a71f4 | |||
| 87e46e576a | |||
| dd055ee6e3 |
@@ -169,6 +169,26 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
|
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
|
||||||
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
|
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Host Docker access (explicit opt-in)
|
||||||
|
# ============================================================
|
||||||
|
# Default Docker Compose does not mount /var/run/docker.sock. Existing
|
||||||
|
# Ollama, vLLM, and other OpenAI-compatible endpoints remain usable without it.
|
||||||
|
#
|
||||||
|
# Enable this only for intentional Cookbook/local Docker-daemon management.
|
||||||
|
# Raw socket access is high-trust and can grant broad control over the host
|
||||||
|
# Docker daemon. Set DOCKER_GID to the host docker group's numeric GID.
|
||||||
|
# Put these values in .env, or export them before running docker compose.
|
||||||
|
# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
|
||||||
|
# DOCKER_GID=963
|
||||||
|
# docker/host-docker.yml sets this inside the container. Keep it paired
|
||||||
|
# with the socket overlay; setting it alone is not sufficient.
|
||||||
|
# ODYSSEUS_ENABLE_HOST_DOCKER=true
|
||||||
|
#
|
||||||
|
# Host Docker access can be combined with one GPU overlay:
|
||||||
|
# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml
|
||||||
|
# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# GPU support (Docker Compose)
|
# GPU support (Docker Compose)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -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
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
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:
|
python-syntax:
|
||||||
name: Python syntax (compileall)
|
name: Python syntax (compileall)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop.
|
# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop.
|
||||||
# When started via `python -m uvicorn` from a terminal, uvicorn sets this
|
# When started via `python -m uvicorn` from a terminal, uvicorn sets this
|
||||||
@@ -197,7 +198,50 @@ class _RequestTimeoutMiddleware(_BaseHTTPMiddleware):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _InteractiveActivityMiddleware(_BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
from src.interactive_gate import should_track_interactive_request, track_interactive_request
|
||||||
|
|
||||||
|
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(_RequestTimeoutMiddleware)
|
||||||
|
app.add_middleware(_InteractiveActivityMiddleware)
|
||||||
|
app.add_middleware(_SlowRequestLogMiddleware)
|
||||||
|
|
||||||
# ========= AUTH =========
|
# ========= AUTH =========
|
||||||
from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
|
from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
|
||||||
@@ -583,6 +627,20 @@ webhook_manager = WebhookManager(api_key_manager=api_key_manager)
|
|||||||
auth_router = setup_auth_routes(auth_manager)
|
auth_router = setup_auth_routes(auth_manager)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/activity/heartbeat")
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
from routes.upload_routes import setup_upload_routes
|
from routes.upload_routes import setup_upload_routes
|
||||||
upload_router, upload_cleanup_func = setup_upload_routes(upload_handler)
|
upload_router, upload_cleanup_func = setup_upload_routes(upload_handler)
|
||||||
@@ -604,7 +662,7 @@ from routes.admin_wipe_routes import setup_admin_wipe_routes
|
|||||||
app.include_router(setup_admin_wipe_routes(session_manager))
|
app.include_router(setup_admin_wipe_routes(session_manager))
|
||||||
|
|
||||||
# Memory
|
# Memory
|
||||||
from routes.memory_routes import setup_memory_routes
|
from routes.memory.memory_routes import setup_memory_routes
|
||||||
memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector)
|
memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector)
|
||||||
app.include_router(memory_router)
|
app.include_router(memory_router)
|
||||||
from routes.skills_routes import setup_skills_routes
|
from routes.skills_routes import setup_skills_routes
|
||||||
@@ -625,7 +683,7 @@ from routes.research.research_routes import setup_research_routes
|
|||||||
app.include_router(setup_research_routes(research_handler, session_manager=session_manager))
|
app.include_router(setup_research_routes(research_handler, session_manager=session_manager))
|
||||||
|
|
||||||
# History
|
# 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))
|
app.include_router(setup_history_routes(session_manager))
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
@@ -793,7 +851,7 @@ from routes.vault_routes import setup_vault_routes
|
|||||||
app.include_router(setup_vault_routes())
|
app.include_router(setup_vault_routes())
|
||||||
|
|
||||||
# Contacts (CardDAV)
|
# 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())
|
app.include_router(setup_contacts_routes())
|
||||||
|
|
||||||
from companion import setup_companion_routes
|
from companion import setup_companion_routes
|
||||||
@@ -869,6 +927,34 @@ async def get_version():
|
|||||||
async def health_check() -> Dict[str, str]:
|
async def health_check() -> Dict[str, str]:
|
||||||
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
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")
|
@app.get("/api/ready")
|
||||||
async def readiness_check() -> JSONResponse:
|
async def readiness_check() -> JSONResponse:
|
||||||
"""Readiness / integrity self-check — DB, data dir, local-first storage.
|
"""Readiness / integrity self-check — DB, data dir, local-first storage.
|
||||||
@@ -965,11 +1051,11 @@ async def _startup_event():
|
|||||||
|
|
||||||
_startup_tasks.append(asyncio.create_task(_startup_mcp_connections()))
|
_startup_tasks.append(asyncio.create_task(_startup_mcp_connections()))
|
||||||
|
|
||||||
# Pre-warm the RAG tool index off the request path. Loading the local
|
# Startup warmups are opt-in. They make later requests a little warmer, but
|
||||||
# embedding model + opening ChromaDB + indexing the built-in tools is a
|
# they also compete with the first seconds of real UI use on slow or busy
|
||||||
# one-time ~1-3s cost that otherwise lands on the user's FIRST message
|
# machines. Default to clear/idle startup and let requests warm what they use.
|
||||||
# (showing up as a big `tool_selection` time). Doing it here makes the
|
_startup_warmups_enabled = str(os.getenv("ODYSSEUS_STARTUP_WARMUPS", "")).lower() in {"1", "true", "yes", "on"}
|
||||||
# first turn as fast as subsequent ones (warm embed ≈ a few ms).
|
if _startup_warmups_enabled:
|
||||||
async def _warmup_tool_index():
|
async def _warmup_tool_index():
|
||||||
try:
|
try:
|
||||||
from src.tool_index import get_tool_index
|
from src.tool_index import get_tool_index
|
||||||
@@ -981,14 +1067,10 @@ async def _startup_event():
|
|||||||
logger.warning(f"Tool index warmup failed (non-critical): {type(e).__name__}: {e}")
|
logger.warning(f"Tool index warmup failed (non-critical): {type(e).__name__}: {e}")
|
||||||
|
|
||||||
_startup_tasks.append(asyncio.create_task(_warmup_tool_index()))
|
_startup_tasks.append(asyncio.create_task(_warmup_tool_index()))
|
||||||
# Warmup: ping all known LLM endpoints to prime connections
|
|
||||||
async def _warmup_endpoints():
|
async def _warmup_endpoints():
|
||||||
try:
|
try:
|
||||||
import httpx
|
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 = (
|
urls = (
|
||||||
await asyncio.to_thread(model_discovery.warmup_ping_urls)
|
await asyncio.to_thread(model_discovery.warmup_ping_urls)
|
||||||
if model_discovery else []
|
if model_discovery else []
|
||||||
@@ -1004,8 +1086,14 @@ async def _startup_event():
|
|||||||
logger.debug(f"Warmup ping skipped: {e}")
|
logger.debug(f"Warmup ping skipped: {e}")
|
||||||
|
|
||||||
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
|
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
|
||||||
|
else:
|
||||||
|
logger.info("Startup warmups disabled (set ODYSSEUS_STARTUP_WARMUPS=1 to enable)")
|
||||||
|
|
||||||
# Keep-alive: ping endpoints every 60 seconds to prevent cold starts
|
# Keep-alive is opt-in. The ping path performs model discovery, and when
|
||||||
|
# stale LAN endpoints are configured it can add periodic backend pressure
|
||||||
|
# that delays unrelated UI requests such as Notes/Documents.
|
||||||
|
_keepalive_enabled = str(os.getenv("ODYSSEUS_MODEL_KEEPALIVE", "")).lower() in {"1", "true", "yes", "on"}
|
||||||
|
if _keepalive_enabled:
|
||||||
async def _keepalive_loop():
|
async def _keepalive_loop():
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ class GalleryImage(TimestampMixin, Base):
|
|||||||
id = Column(String, primary_key=True, index=True)
|
id = Column(String, primary_key=True, index=True)
|
||||||
filename = Column(String, nullable=False, unique=True)
|
filename = Column(String, nullable=False, unique=True)
|
||||||
prompt = Column(Text, nullable=False, default="")
|
prompt = Column(Text, nullable=False, default="")
|
||||||
|
caption = Column(Text, nullable=True, default="")
|
||||||
model = Column(String, nullable=True)
|
model = Column(String, nullable=True)
|
||||||
size = Column(String, nullable=True)
|
size = Column(String, nullable=True)
|
||||||
quality = Column(String, nullable=True)
|
quality = Column(String, nullable=True)
|
||||||
@@ -1182,6 +1183,29 @@ def _migrate_add_multiuser_owner_columns():
|
|||||||
_migrate_add_owner_to_table("documents", "ix_documents_owner")
|
_migrate_add_owner_to_table("documents", "ix_documents_owner")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_add_gallery_caption_column():
|
||||||
|
"""Add OCR/vision caption storage for gallery images."""
|
||||||
|
import sqlite3
|
||||||
|
db_path = DATABASE_URL.replace("sqlite:///", "")
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
return
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
columns = [row[1] for row in conn.execute("PRAGMA table_info(gallery_images)").fetchall()]
|
||||||
|
if columns and "caption" not in columns:
|
||||||
|
conn.execute("ALTER TABLE gallery_images ADD COLUMN caption TEXT DEFAULT ''")
|
||||||
|
conn.commit()
|
||||||
|
logging.getLogger(__name__).info("Migrated: added caption column to gallery_images")
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).warning(f"Migration gallery caption column failed: {e}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _migrate_add_api_token_scopes_column():
|
def _migrate_add_api_token_scopes_column():
|
||||||
"""Add API token scopes for existing installs.
|
"""Add API token scopes for existing installs.
|
||||||
|
|
||||||
@@ -1670,6 +1694,7 @@ class CalendarEvent(TimestampMixin, Base):
|
|||||||
# `Z`-suffix on serialization so the frontend interprets correctly.
|
# `Z`-suffix on serialization so the frontend interprets correctly.
|
||||||
is_utc = Column(Boolean, default=False, nullable=False)
|
is_utc = Column(Boolean, default=False, nullable=False)
|
||||||
rrule = Column(String, default="")
|
rrule = Column(String, default="")
|
||||||
|
recurrence_exdates = Column(Text, default="") # JSON list of skipped occurrence starts
|
||||||
color = Column(String, nullable=True) # per-event color override
|
color = Column(String, nullable=True) # per-event color override
|
||||||
status = Column(String, default="confirmed") # confirmed, cancelled
|
status = Column(String, default="confirmed") # confirmed, cancelled
|
||||||
importance = Column(String, default="normal") # low | normal | high | critical
|
importance = Column(String, default="normal") # low | normal | high | critical
|
||||||
@@ -1811,6 +1836,7 @@ def init_db():
|
|||||||
_migrate_add_token_columns()
|
_migrate_add_token_columns()
|
||||||
_migrate_add_mode_column()
|
_migrate_add_mode_column()
|
||||||
_migrate_add_multiuser_owner_columns()
|
_migrate_add_multiuser_owner_columns()
|
||||||
|
_migrate_add_gallery_caption_column()
|
||||||
_migrate_add_api_token_scopes_column()
|
_migrate_add_api_token_scopes_column()
|
||||||
_migrate_backfill_document_owner_from_session()
|
_migrate_backfill_document_owner_from_session()
|
||||||
_migrate_assign_legacy_owner()
|
_migrate_assign_legacy_owner()
|
||||||
@@ -1833,6 +1859,7 @@ def init_db():
|
|||||||
_migrate_add_calendar_origin()
|
_migrate_add_calendar_origin()
|
||||||
_migrate_add_calendar_account_id()
|
_migrate_add_calendar_account_id()
|
||||||
_migrate_add_caldav_sync_columns()
|
_migrate_add_caldav_sync_columns()
|
||||||
|
_migrate_add_calendar_recurrence_exdates()
|
||||||
_migrate_chat_messages_fts()
|
_migrate_chat_messages_fts()
|
||||||
_migrate_encrypt_email_passwords()
|
_migrate_encrypt_email_passwords()
|
||||||
_migrate_encrypt_signatures()
|
_migrate_encrypt_signatures()
|
||||||
@@ -2184,6 +2211,28 @@ def _migrate_add_calendar_metadata():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_add_calendar_recurrence_exdates():
|
||||||
|
"""Add skipped recurrence occurrences for deleting one instance of a series."""
|
||||||
|
import sqlite3
|
||||||
|
db_path = DATABASE_URL.replace("sqlite:///", "")
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
return
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
columns = [row[1] for row in conn.execute("PRAGMA table_info(calendar_events)").fetchall()]
|
||||||
|
if columns and "recurrence_exdates" not in columns:
|
||||||
|
conn.execute("ALTER TABLE calendar_events ADD COLUMN recurrence_exdates TEXT DEFAULT ''")
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).warning(f"calendar_events recurrence_exdates migration failed: {e}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""
|
"""
|
||||||
Dependency to get a database session.
|
Dependency to get a database session.
|
||||||
|
|||||||
+1
-1
@@ -117,7 +117,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
|
f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
|
||||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||||
"font-src 'self' https://cdn.jsdelivr.net; "
|
"font-src 'self' https://cdn.jsdelivr.net; "
|
||||||
"img-src 'self' data: blob:; "
|
"img-src 'self' data: blob: https:; "
|
||||||
"media-src 'self' blob:; "
|
"media-src 'self' blob:; "
|
||||||
"connect-src 'self'; "
|
"connect-src 'self'; "
|
||||||
"frame-src 'self'; "
|
"frame-src 'self'; "
|
||||||
|
|||||||
@@ -28,14 +28,6 @@ services:
|
|||||||
# land under /app/.local for the odysseus user. Persist them so a
|
# land under /app/.local for the odysseus user. Persist them so a
|
||||||
# container recreate does not silently remove installed serve engines.
|
# container recreate does not silently remove installed serve engines.
|
||||||
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
||||||
# Docker socket — lets Cookbook launch commands like
|
|
||||||
# `docker exec ollama-rocm ollama show <tag>` reach the host's
|
|
||||||
# Docker daemon (and sibling containers like ollama-rocm /
|
|
||||||
# ollama-test). The in-container user needs to be in the
|
|
||||||
# socket's owning group — see `group_add` below; the GID
|
|
||||||
# there must match the host's `docker` group (defaults to 963
|
|
||||||
# on Debian, 999 on Ubuntu — override via env if yours differs).
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# Lets the container reach local services on the Docker host, including
|
# Lets the container reach local services on the Docker host, including
|
||||||
# Ollama at http://host.docker.internal:11434.
|
# Ollama at http://host.docker.internal:11434.
|
||||||
@@ -101,7 +93,6 @@ services:
|
|||||||
- /dev/kfd
|
- /dev/kfd
|
||||||
- /dev/dri
|
- /dev/dri
|
||||||
group_add:
|
group_add:
|
||||||
- "${DOCKER_GID:-963}"
|
|
||||||
- video
|
- video
|
||||||
- ${RENDER_GID:-render}
|
- ${RENDER_GID:-render}
|
||||||
|
|
||||||
|
|||||||
@@ -27,16 +27,6 @@ services:
|
|||||||
# land under /app/.local for the odysseus user. Persist them so a
|
# land under /app/.local for the odysseus user. Persist them so a
|
||||||
# container recreate does not silently remove installed serve engines.
|
# container recreate does not silently remove installed serve engines.
|
||||||
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
||||||
# Docker socket — lets Cookbook launch commands like
|
|
||||||
# `docker exec ollama-rocm ollama show <tag>` reach the host's
|
|
||||||
# Docker daemon (and sibling containers like ollama-rocm /
|
|
||||||
# ollama-test). The in-container user needs to be in the
|
|
||||||
# socket's owning group — see `group_add` below; the GID
|
|
||||||
# there must match the host's `docker` group (defaults to 963
|
|
||||||
# on Debian, 999 on Ubuntu — override via env if yours differs).
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
group_add:
|
|
||||||
- "${DOCKER_GID:-963}"
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# Lets the container reach local services on the Docker host, including
|
# Lets the container reach local services on the Docker host, including
|
||||||
# Ollama at http://host.docker.internal:11434.
|
# Ollama at http://host.docker.internal:11434.
|
||||||
|
|||||||
@@ -16,16 +16,6 @@ services:
|
|||||||
# land under /app/.local for the odysseus user. Persist them so a
|
# land under /app/.local for the odysseus user. Persist them so a
|
||||||
# container recreate does not silently remove installed serve engines.
|
# container recreate does not silently remove installed serve engines.
|
||||||
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
||||||
# Docker socket — lets Cookbook launch commands like
|
|
||||||
# `docker exec ollama-rocm ollama show <tag>` reach the host's
|
|
||||||
# Docker daemon (and sibling containers like ollama-rocm /
|
|
||||||
# ollama-test). The in-container user needs to be in the
|
|
||||||
# socket's owning group — see `group_add` below; the GID
|
|
||||||
# there must match the host's `docker` group (defaults to 963
|
|
||||||
# on Debian, 999 on Ubuntu — override via env if yours differs).
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
group_add:
|
|
||||||
- "${DOCKER_GID:-963}"
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# Lets the container reach local services on the Docker host, including
|
# Lets the container reach local services on the Docker host, including
|
||||||
# Ollama at http://host.docker.internal:11434.
|
# Ollama at http://host.docker.internal:11434.
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ fi
|
|||||||
ODY_USER="$(getent passwd "$PUID" | cut -d: -f1)"
|
ODY_USER="$(getent passwd "$PUID" | cut -d: -f1)"
|
||||||
[ -z "$ODY_USER" ] && ODY_USER=odysseus
|
[ -z "$ODY_USER" ] && ODY_USER=odysseus
|
||||||
|
|
||||||
# Docker-socket group plumbing. When /var/run/docker.sock is bind-mounted
|
# Docker-socket group plumbing for the explicit host-Docker overlay. When
|
||||||
# (Cookbook uses docker exec to reach sibling containers), the socket is
|
# opted in, the socket is owned by root:<host docker gid>. Add the app user
|
||||||
# owned by root:<host docker gid>. Add the app user to that group and later
|
# to that group and later call gosu by username so supplementary groups are
|
||||||
# call gosu by username so supplementary groups are retained.
|
# retained.
|
||||||
DOCKER_SOCK="${DOCKER_SOCK:-/var/run/docker.sock}"
|
DOCKER_SOCK="${DOCKER_SOCK:-/var/run/docker.sock}"
|
||||||
if [ -S "$DOCKER_SOCK" ]; then
|
if [ "${ODYSSEUS_ENABLE_HOST_DOCKER:-}" = "true" ] && [ -S "$DOCKER_SOCK" ]; then
|
||||||
SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK" 2>/dev/null || echo '')"
|
SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK" 2>/dev/null || echo '')"
|
||||||
if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "0" ]; then
|
if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "0" ]; then
|
||||||
if ! getent group "$SOCK_GID" >/dev/null 2>&1; then
|
if ! getent group "$SOCK_GID" >/dev/null 2>&1; then
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# High-trust host Docker access. Enable only when local Docker-daemon
|
||||||
|
# management from Cookbook is required and you accept that raw socket access
|
||||||
|
# grants broad control over the host Docker daemon.
|
||||||
|
# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
|
||||||
|
# DOCKER_GID=<numeric host Docker group id>
|
||||||
|
services:
|
||||||
|
odysseus:
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
group_add: ["${DOCKER_GID:-963}"]
|
||||||
|
environment:
|
||||||
|
- ODYSSEUS_ENABLE_HOST_DOCKER=true
|
||||||
+28
-1
@@ -99,6 +99,33 @@ Odysseus SSH key and add the public key to the remote server's
|
|||||||
ssh-copy-id -i data/ssh/id_ed25519.pub user@server
|
ssh-copy-id -i data/ssh/id_ed25519.pub user@server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Host Docker access (explicit opt-in).** Default Docker Compose intentionally
|
||||||
|
does not mount `/var/run/docker.sock`. You can still connect Odysseus to
|
||||||
|
existing Ollama, vLLM, and other OpenAI-compatible endpoints without Docker
|
||||||
|
socket access.
|
||||||
|
|
||||||
|
Cookbook/local Docker-daemon management requires the opt-in overlay below. Raw
|
||||||
|
Docker socket access is high-trust because it can effectively grant broad
|
||||||
|
control over the host Docker daemon. Remote server Docker workflows over SSH
|
||||||
|
remain preferred.
|
||||||
|
|
||||||
|
Place these values in `.env`, or export them in the shell before running
|
||||||
|
`docker compose`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
|
||||||
|
DOCKER_GID=<host docker group gid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Combine host Docker access with a GPU overlay when both are intentionally
|
||||||
|
required:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml
|
||||||
|
# or
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml
|
||||||
|
```
|
||||||
|
|
||||||
**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can
|
**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can
|
||||||
only detect GPUs that Docker exposes to the container — if the host runtime or
|
only detect GPUs that Docker exposes to the container — if the host runtime or
|
||||||
device passthrough is not configured, Cookbook sees the iGPU, another card, or
|
device passthrough is not configured, Cookbook sees the iGPU, another card, or
|
||||||
@@ -445,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`.
|
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
|
||||||
|
|
||||||
To back up or restore everything in `data/`, see the
|
To back up or restore everything in `data/`, see the
|
||||||
[Backup & Restore guide](docs/backup-restore.md).
|
[Backup & Restore guide](backup-restore.md).
|
||||||
|
|||||||
+196
-14
@@ -58,6 +58,11 @@ def _uid_fetch_rows(data) -> list:
|
|||||||
_ACCOUNT_CACHE: dict = {} # key = normalized account selector -> config dict
|
_ACCOUNT_CACHE: dict = {} # key = normalized account selector -> config dict
|
||||||
_MCP_OWNER_ARG = "_odysseus_owner"
|
_MCP_OWNER_ARG = "_odysseus_owner"
|
||||||
_CURRENT_OWNER: ContextVar[str | None] = ContextVar("email_mcp_owner", default=None)
|
_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:
|
def _clean_header_value(value) -> str:
|
||||||
@@ -71,13 +76,29 @@ def _db_path() -> Path:
|
|||||||
return Path(APP_DB)
|
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:
|
def _current_owner() -> str:
|
||||||
owner = _CURRENT_OWNER.get()
|
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:
|
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:
|
if row_owner == owner:
|
||||||
return True
|
return True
|
||||||
if row_owner:
|
if row_owner:
|
||||||
@@ -96,8 +117,7 @@ def _filter_accounts_for_owner(rows: list[dict]) -> list[dict]:
|
|||||||
if owner:
|
if owner:
|
||||||
return [r for r in rows if _account_visible_to_owner(r, 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 _has_owner_scoped_accounts(rows):
|
||||||
if len(owners) > 1:
|
|
||||||
return []
|
return []
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
@@ -106,8 +126,7 @@ def _mcp_owner_required(rows: list[dict] | None = None) -> bool:
|
|||||||
if _current_owner():
|
if _current_owner():
|
||||||
return False
|
return False
|
||||||
rows = rows if rows is not None else _read_accounts_from_db()
|
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 _has_owner_scoped_accounts(rows)
|
||||||
return len(owners) > 1
|
|
||||||
|
|
||||||
|
|
||||||
def _load_email_writing_style() -> str:
|
def _load_email_writing_style() -> str:
|
||||||
@@ -274,6 +293,8 @@ def _load_config(account: str | None = None) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
raw_rows = _read_accounts_from_db()
|
raw_rows = _read_accounts_from_db()
|
||||||
|
if _mcp_owner_required(raw_rows):
|
||||||
|
raise ValueError(_OWNER_SCOPE_ERROR)
|
||||||
rows = _filter_accounts_for_owner(raw_rows)
|
rows = _filter_accounts_for_owner(raw_rows)
|
||||||
row = _resolve_account_from_rows(rows, account)
|
row = _resolve_account_from_rows(rows, account)
|
||||||
if _current_owner() and raw_rows and not rows:
|
if _current_owner() and raw_rows and not rows:
|
||||||
@@ -538,6 +559,148 @@ def _get_cached_summaries():
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_email_file() -> Path:
|
||||||
|
return DATA_DIR / "fixture_email_messages.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_email_enabled() -> bool:
|
||||||
|
return _fixture_email_file().exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_fixture_date(raw_date: str) -> tuple[str, float]:
|
||||||
|
if not raw_date:
|
||||||
|
return "", 0.0
|
||||||
|
parsed = None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(str(raw_date).replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
parsed = email.utils.parsedate_to_datetime(str(raw_date))
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
if parsed:
|
||||||
|
return parsed.isoformat(), parsed.timestamp()
|
||||||
|
return str(raw_date), 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
|
||||||
|
sender = str(row.get("from") or "Fixture Sender <fixture@example.invalid>")
|
||||||
|
sender_name, sender_addr = email.utils.parseaddr(sender)
|
||||||
|
date_str, date_epoch = _parse_fixture_date(str(row.get("date") or ""))
|
||||||
|
subject = str(row.get("subject") or "(no subject)")
|
||||||
|
body = str(row.get("body") or "")
|
||||||
|
owner_key = re.sub(r"[^A-Za-z0-9_.-]", "-", owner or "default")
|
||||||
|
uid = str(uid_num)
|
||||||
|
return {
|
||||||
|
"uid": uid,
|
||||||
|
"message_id": f"<fixture-email-{uid}-{owner_key}@fixtures.odysseus.local>",
|
||||||
|
"subject": subject,
|
||||||
|
"from": sender_name or sender_addr or sender,
|
||||||
|
"from_address": sender_addr,
|
||||||
|
"date": date_str,
|
||||||
|
"date_epoch": date_epoch,
|
||||||
|
"summary": body[:240],
|
||||||
|
"body": body,
|
||||||
|
"account": "Fixture Inbox",
|
||||||
|
"account_email": owner or str(row.get("owner") or ""),
|
||||||
|
"account_id": "fixture-email",
|
||||||
|
"attachments": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_email_rows(owner: str | None = None) -> list[dict]:
|
||||||
|
path = _fixture_email_file()
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
rows = raw.get("messages") if isinstance(raw, dict) else raw
|
||||||
|
out = []
|
||||||
|
owner = str(owner or "").strip()
|
||||||
|
for i, row in enumerate(rows if isinstance(rows, list) else [], start=1):
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
row_owner = str(row.get("owner") or "").strip()
|
||||||
|
if owner and row_owner and row_owner != owner:
|
||||||
|
continue
|
||||||
|
out.append(_fixture_email_record(row, i, owner or row_owner))
|
||||||
|
out.sort(key=lambda item: item.get("date_epoch") or 0, reverse=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_account_rows() -> list[dict]:
|
||||||
|
if not _fixture_email_enabled():
|
||||||
|
return []
|
||||||
|
owner = _current_owner()
|
||||||
|
owners = []
|
||||||
|
for row in _fixture_email_rows(owner or None):
|
||||||
|
email_addr = row.get("account_email") or owner or "fixture@fixtures.odysseus.local"
|
||||||
|
if email_addr not in owners:
|
||||||
|
owners.append(email_addr)
|
||||||
|
if not owners:
|
||||||
|
owners = [owner or "fixture@fixtures.odysseus.local"]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "fixture-email",
|
||||||
|
"owner": owner or owners[0],
|
||||||
|
"name": "Fixture Inbox",
|
||||||
|
"is_default": True,
|
||||||
|
"imap_user": owners[0],
|
||||||
|
"from_address": owners[0],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_email_matches(item: dict, query: str) -> bool:
|
||||||
|
if not query:
|
||||||
|
return True
|
||||||
|
terms = [term for term in re.split(r"\W+", str(query).lower()) if term]
|
||||||
|
haystack = "\n".join(
|
||||||
|
str(item.get(key) or "")
|
||||||
|
for key in ("subject", "from", "from_address", "body", "summary")
|
||||||
|
).lower()
|
||||||
|
return all(term in haystack for term in terms)
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_list_emails(folder="INBOX", max_results=20, unresponded_only=False,
|
||||||
|
unread_only=False, account=None) -> list[dict] | None:
|
||||||
|
if not _fixture_email_enabled():
|
||||||
|
return None
|
||||||
|
if account and str(account).strip().lower() not in {
|
||||||
|
"fixture-email",
|
||||||
|
"fixture inbox",
|
||||||
|
"fixture",
|
||||||
|
str(_current_owner()).lower(),
|
||||||
|
}:
|
||||||
|
return []
|
||||||
|
if (folder or "INBOX").upper() not in {"INBOX", "ALL", "ALL MAIL"}:
|
||||||
|
return []
|
||||||
|
return _fixture_email_rows(_current_owner())[: int(max_results or 20)]
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_search_emails(query, folders=None, max_results=20, account=None) -> list[dict] | None:
|
||||||
|
if not _fixture_email_enabled():
|
||||||
|
return None
|
||||||
|
rows = _fixture_list_emails("INBOX", max_results=1000, account=account) or []
|
||||||
|
out = [dict(row, _folder="INBOX") for row in rows if _fixture_email_matches(row, str(query or ""))]
|
||||||
|
return out[: int(max_results or 20)]
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_read_email(uid=None, message_id=None, folder="INBOX", account=None) -> dict | None:
|
||||||
|
if not _fixture_email_enabled():
|
||||||
|
return None
|
||||||
|
if (folder or "INBOX").upper() not in {"INBOX", "ALL", "ALL MAIL"}:
|
||||||
|
return {"error": f"Email UID {uid or message_id} not found"}
|
||||||
|
for item in _fixture_email_rows(_current_owner()):
|
||||||
|
if uid and str(item.get("uid")) == str(uid):
|
||||||
|
return item
|
||||||
|
if message_id and str(item.get("message_id")) == str(message_id):
|
||||||
|
return item
|
||||||
|
return {"error": f"Email not found with UID/Message-ID: {uid or message_id}"}
|
||||||
|
|
||||||
|
|
||||||
# ── Tool implementations ──
|
# ── Tool implementations ──
|
||||||
|
|
||||||
|
|
||||||
@@ -548,6 +711,9 @@ def _list_emails(folder="INBOX", max_results=20, unresponded_only=False,
|
|||||||
Pass unread_only=True and/or unresponded_only=True for attention scans.
|
Pass unread_only=True and/or unresponded_only=True for attention scans.
|
||||||
account selects mailbox (None = default).
|
account selects mailbox (None = default).
|
||||||
"""
|
"""
|
||||||
|
fixture = _fixture_list_emails(folder, max_results, unresponded_only, unread_only, account)
|
||||||
|
if fixture is not None:
|
||||||
|
return fixture
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = _imap_connect(account)
|
conn = _imap_connect(account)
|
||||||
@@ -629,6 +795,9 @@ def _result_sort_time(result: dict) -> datetime:
|
|||||||
|
|
||||||
def _list_emails_across_accounts(folder="INBOX", max_results=20,
|
def _list_emails_across_accounts(folder="INBOX", max_results=20,
|
||||||
unresponded_only=False, unread_only=False):
|
unresponded_only=False, unread_only=False):
|
||||||
|
fixture = _fixture_list_emails(folder, max_results, unresponded_only, unread_only, None)
|
||||||
|
if fixture is not None:
|
||||||
|
return fixture, []
|
||||||
rows = _list_accounts_raw()
|
rows = _list_accounts_raw()
|
||||||
combined = []
|
combined = []
|
||||||
errors = []
|
errors = []
|
||||||
@@ -662,6 +831,9 @@ def _search_emails(query, folders=None, max_results=20, account=None):
|
|||||||
_list_emails plus an `_folder` tag."""
|
_list_emails plus an `_folder` tag."""
|
||||||
if not query or not str(query).strip():
|
if not query or not str(query).strip():
|
||||||
return []
|
return []
|
||||||
|
fixture = _fixture_search_emails(query, folders=folders, max_results=max_results, account=account)
|
||||||
|
if fixture is not None:
|
||||||
|
return fixture
|
||||||
q = str(query).replace("\\", "\\\\").replace('"', '\\"')
|
q = str(query).replace("\\", "\\\\").replace('"', '\\"')
|
||||||
# Mail clients commonly use OR FROM/SUBJECT/TEXT to match either field.
|
# Mail clients commonly use OR FROM/SUBJECT/TEXT to match either field.
|
||||||
# IMAP SEARCH OR is binary, so we nest it.
|
# IMAP SEARCH OR is binary, so we nest it.
|
||||||
@@ -784,6 +956,9 @@ def _extract_attachment_to_disk(msg, index, target_dir):
|
|||||||
|
|
||||||
def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
||||||
"""Read full email content by UID or message-ID. account = mailbox selector."""
|
"""Read full email content by UID or message-ID. account = mailbox selector."""
|
||||||
|
fixture = _fixture_read_email(uid=uid, message_id=message_id, folder=folder, account=account)
|
||||||
|
if fixture is not None:
|
||||||
|
return fixture
|
||||||
cfg = _load_config(account)
|
cfg = _load_config(account)
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
@@ -837,6 +1012,9 @@ def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
|||||||
|
|
||||||
|
|
||||||
def _read_email_across_accounts(uid=None, message_id=None, folder="INBOX"):
|
def _read_email_across_accounts(uid=None, message_id=None, folder="INBOX"):
|
||||||
|
fixture = _fixture_read_email(uid=uid, message_id=message_id, folder=folder, account=None)
|
||||||
|
if fixture is not None:
|
||||||
|
return fixture
|
||||||
rows = _list_accounts_raw()
|
rows = _list_accounts_raw()
|
||||||
matches = []
|
matches = []
|
||||||
errors = []
|
errors = []
|
||||||
@@ -1036,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
|
UI. This closes the auto-send hole that let earlier models invent
|
||||||
signatures and ship them to real recipients without confirmation."""
|
signatures and ship them to real recipients without confirmation."""
|
||||||
if _read_agent_email_confirm_setting():
|
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(
|
return _stash_agent_draft(
|
||||||
to=to, subject=subject, body=body,
|
to=to, subject=subject, body=body,
|
||||||
in_reply_to=in_reply_to, references=references,
|
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)
|
send_account, cfg = _resolve_send_config(account)
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
@@ -1775,9 +1957,10 @@ async def list_tools() -> list[Tool]:
|
|||||||
Tool(
|
Tool(
|
||||||
name="reply_to_email",
|
name="reply_to_email",
|
||||||
description=(
|
description=(
|
||||||
"Reply to an existing email by UID. This sends immediately; for normal "
|
"Reply to an existing email by UID. This sends immediately. Do NOT use "
|
||||||
"assistant-written replies, prefer draft_email_reply so the user can "
|
"for normal 'write/draft a reply saying X' requests; use "
|
||||||
"review and send from Odysseus. Automatically threads the reply with "
|
"draft_email_reply so the user can review and send from Odysseus. "
|
||||||
|
"Only use this when the user explicitly says to send now. Automatically threads the reply with "
|
||||||
"In-Reply-To and References headers, prefixes 'Re:' on the subject, and "
|
"In-Reply-To and References headers, prefixes 'Re:' on the subject, and "
|
||||||
"uses the original sender as the recipient. Set reply_all=true to also CC "
|
"uses the original sender as the recipient. Set reply_all=true to also CC "
|
||||||
"the original To/Cc recipients. For follow-up 'reply ...' requests, use "
|
"the original To/Cc recipients. For follow-up 'reply ...' requests, use "
|
||||||
@@ -1984,13 +2167,12 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
try:
|
try:
|
||||||
all_db_accounts = _read_accounts_from_db()
|
all_db_accounts = _read_accounts_from_db()
|
||||||
if _mcp_owner_required(all_db_accounts):
|
if _mcp_owner_required(all_db_accounts):
|
||||||
return [TextContent(
|
return [TextContent(type="text", text=_OWNER_SCOPE_ERROR)]
|
||||||
type="text",
|
|
||||||
text="Error: email MCP requires an authenticated owner when multiple email account owners are configured.",
|
|
||||||
)]
|
|
||||||
|
|
||||||
if name == "list_email_accounts":
|
if name == "list_email_accounts":
|
||||||
rows = _filter_accounts_for_owner(all_db_accounts)
|
rows = _filter_accounts_for_owner(all_db_accounts)
|
||||||
|
if not rows:
|
||||||
|
rows = _fixture_account_rows()
|
||||||
if not rows:
|
if not rows:
|
||||||
if all_db_accounts and owner:
|
if all_db_accounts and owner:
|
||||||
return [TextContent(type="text", text="No email accounts configured for this owner.")]
|
return [TextContent(type="text", text="No email accounts configured for this owner.")]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ uvicorn
|
|||||||
python-multipart
|
python-multipart
|
||||||
python-dotenv
|
python-dotenv
|
||||||
httpx
|
httpx
|
||||||
|
httpcore>=1.0,<2.0
|
||||||
pydantic>=2.13.4
|
pydantic>=2.13.4
|
||||||
pydantic-settings>=2.14.1
|
pydantic-settings>=2.14.1
|
||||||
SQLAlchemy
|
SQLAlchemy
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Calendar routes — local SQLite-backed calendar CRUD."""
|
"""Calendar routes — local SQLite-backed calendar CRUD."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
@@ -541,6 +542,7 @@ def _event_to_dict(ev: CalendarEvent) -> dict:
|
|||||||
"description": ev.description or "",
|
"description": ev.description or "",
|
||||||
"location": ev.location or "",
|
"location": ev.location or "",
|
||||||
"rrule": ev.rrule or "",
|
"rrule": ev.rrule or "",
|
||||||
|
"recurrence_exdates": _recurrence_exdates(ev),
|
||||||
"calendar": ev.calendar.name if ev.calendar else "",
|
"calendar": ev.calendar.name if ev.calendar else "",
|
||||||
"calendar_href": ev.calendar_id,
|
"calendar_href": ev.calendar_id,
|
||||||
"color": ev.color or (ev.calendar.color if ev.calendar else ""),
|
"color": ev.color or (ev.calendar.color if ev.calendar else ""),
|
||||||
@@ -554,6 +556,28 @@ def _event_to_dict(ev: CalendarEvent) -> dict:
|
|||||||
_RRULE_EXPANSION_LIMIT = 1000
|
_RRULE_EXPANSION_LIMIT = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def _recurrence_exdates(ev: CalendarEvent) -> list[str]:
|
||||||
|
raw = getattr(ev, "recurrence_exdates", "") or ""
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
values = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return []
|
||||||
|
return [str(v) for v in values if isinstance(v, str) and v.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _occurrence_exdate_key(uid: str, ev: CalendarEvent) -> str:
|
||||||
|
if "::" not in uid:
|
||||||
|
return ""
|
||||||
|
suffix = uid.split("::", 1)[1]
|
||||||
|
if ev.all_day:
|
||||||
|
return suffix[:10]
|
||||||
|
return suffix[:16]
|
||||||
|
|
||||||
|
|
||||||
def _expand_rrule(
|
def _expand_rrule(
|
||||||
ev: CalendarEvent, start: datetime, end: datetime
|
ev: CalendarEvent, start: datetime, end: datetime
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
@@ -618,6 +642,7 @@ def _expand_rrule(
|
|||||||
results = []
|
results = []
|
||||||
truncated = False
|
truncated = False
|
||||||
base = _event_to_dict(ev)
|
base = _event_to_dict(ev)
|
||||||
|
exdates = set(_recurrence_exdates(ev))
|
||||||
|
|
||||||
for occ_start in rule.xafter(expand_start, inc=True):
|
for occ_start in rule.xafter(expand_start, inc=True):
|
||||||
if occ_start >= end:
|
if occ_start >= end:
|
||||||
@@ -638,8 +663,13 @@ def _expand_rrule(
|
|||||||
# Build the compound uid: {base_uid}::{date} or ::{datetime}
|
# Build the compound uid: {base_uid}::{date} or ::{datetime}
|
||||||
if ev.all_day:
|
if ev.all_day:
|
||||||
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}"
|
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}"
|
||||||
|
exdate_key = occ_start.strftime("%Y-%m-%d")
|
||||||
else:
|
else:
|
||||||
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%dT%H:%M')}"
|
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%dT%H:%M')}"
|
||||||
|
exdate_key = occ_start.strftime("%Y-%m-%dT%H:%M")
|
||||||
|
|
||||||
|
if exdate_key in exdates:
|
||||||
|
continue
|
||||||
|
|
||||||
d = dict(base)
|
d = dict(base)
|
||||||
d["uid"] = occ_uid
|
d["uid"] = occ_uid
|
||||||
@@ -1150,7 +1180,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@router.delete("/events/{uid}")
|
@router.delete("/events/{uid}")
|
||||||
async def delete_event(request: Request, uid: str):
|
async def delete_event(request: Request, uid: str, scope: str = "series"):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
try:
|
||||||
base_uid = _resolve_base_uid(uid)
|
base_uid = _resolve_base_uid(uid)
|
||||||
@@ -1159,7 +1189,22 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ev = _get_or_404_event(db, base_uid, owner)
|
ev = _get_or_404_event(db, base_uid, owner)
|
||||||
|
is_occurrence_delete = scope in {"occurrence", "instance"} and "::" in uid and bool(ev.rrule)
|
||||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||||
|
if is_occurrence_delete:
|
||||||
|
key = _occurrence_exdate_key(uid, ev)
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(400, "Invalid recurring occurrence uid")
|
||||||
|
exdates = _recurrence_exdates(ev)
|
||||||
|
if key not in exdates:
|
||||||
|
exdates.append(key)
|
||||||
|
ev.recurrence_exdates = json.dumps(sorted(exdates))
|
||||||
|
if is_caldav:
|
||||||
|
ev.caldav_sync_pending = "update"
|
||||||
|
db.commit()
|
||||||
|
if is_caldav:
|
||||||
|
await _push_caldav_event_after_commit(owner, base_uid, "update")
|
||||||
|
return {"ok": True, "scope": "occurrence", "exdate": key}
|
||||||
if is_caldav:
|
if is_caldav:
|
||||||
_record_caldav_delete_tombstone(db, ev, owner)
|
_record_caldav_delete_tombstone(db, ev, owner)
|
||||||
db.delete(ev)
|
db.delete(ev)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from core.database import Session as DBSession, ModelEndpoint
|
|||||||
from src.llm_core import normalize_model_id
|
from src.llm_core import normalize_model_id
|
||||||
from src.endpoint_resolver import normalize_base
|
from src.endpoint_resolver import normalize_base
|
||||||
from src.context_compactor import maybe_compact, trim_for_context
|
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.auth_helpers import effective_user
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from routes.prefs_routes import _load_for_user as load_prefs_for_user
|
from routes.prefs_routes import _load_for_user as load_prefs_for_user
|
||||||
@@ -99,6 +100,11 @@ class ChatContext:
|
|||||||
uprefs: dict
|
uprefs: dict
|
||||||
preset: PresetInfo
|
preset: PresetInfo
|
||||||
preprocessed: PreprocessedMessage
|
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
|
# Documents auto-created server-side during preprocess (e.g. when an
|
||||||
# attached fillable PDF gets rendered into a markdown editor doc).
|
# attached fillable PDF gets rendered into a markdown editor doc).
|
||||||
# The chat route emits a doc_update SSE event for each before streaming
|
# 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(
|
messages, context_length, was_compacted = await maybe_compact(
|
||||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
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)
|
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(
|
return ChatContext(
|
||||||
preface=preface,
|
preface=preface,
|
||||||
@@ -791,6 +802,11 @@ async def build_chat_context(
|
|||||||
uprefs=uprefs,
|
uprefs=uprefs,
|
||||||
preset=preset,
|
preset=preset,
|
||||||
preprocessed=preprocessed,
|
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,
|
auto_opened_docs=auto_opened_docs,
|
||||||
uploaded_files=uploaded_files,
|
uploaded_files=uploaded_files,
|
||||||
)
|
)
|
||||||
|
|||||||
+180
-36
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -40,8 +41,13 @@ from routes.chat_helpers import (
|
|||||||
clean_thinking_for_save,
|
clean_thinking_for_save,
|
||||||
_enforce_chat_privileges,
|
_enforce_chat_privileges,
|
||||||
)
|
)
|
||||||
from src.action_intents import classify_tool_intent as _classify_tool_intent
|
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
|
||||||
from src.tool_policy import build_effective_tool_policy
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -63,6 +69,78 @@ def _stream_set(session_id: str, **fields) -> None:
|
|||||||
rec.update(fields)
|
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:
|
def _resolve_request_workspace(request, raw_value) -> tuple:
|
||||||
"""Resolve the posted workspace for this request: (workspace, rejected).
|
"""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,
|
# below). Skill extraction should only learn from real agent sessions,
|
||||||
# not chats we quietly promoted for a notes/calendar intent.
|
# not chats we quietly promoted for a notes/calendar intent.
|
||||||
user_requested_agent = (chat_mode == "agent")
|
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
|
# Intent auto-escalation: if the user is clearly asking the assistant
|
||||||
# to create a todo, reminder, or calendar event, promote chat → agent
|
# to create a todo, reminder, or calendar event, promote chat → agent
|
||||||
# for this turn so the LLM has access to manage_notes / manage_calendar.
|
# 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.category,
|
||||||
_tool_intent.reason,
|
_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()
|
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}")
|
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,
|
400,
|
||||||
"No model selected for this chat. Open the model picker and choose one before sending.",
|
"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:
|
except SessionNotFoundError as e:
|
||||||
raise HTTPException(404, str(e))
|
raise HTTPException(404, str(e))
|
||||||
except (ValueError, ValidationError):
|
except (ValueError, ValidationError):
|
||||||
@@ -729,6 +826,15 @@ def setup_chat_routes(
|
|||||||
logger.info(f"[doc-inject] found by ID: title={active_doc.title!r}, lang={active_doc.language!r}, is_active={active_doc.is_active}, content_len={len(active_doc.current_content or '')}")
|
logger.info(f"[doc-inject] found by ID: title={active_doc.title!r}, lang={active_doc.language!r}, is_active={active_doc.is_active}, content_len={len(active_doc.current_content or '')}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[doc-inject] NOT FOUND by ID {active_doc_id}")
|
logger.warning(f"[doc-inject] NOT FOUND by ID {active_doc_id}")
|
||||||
|
if not active_doc:
|
||||||
|
_email_doc_q = _doc_db.query(DBDocument).filter(
|
||||||
|
DBDocument.session_id == session,
|
||||||
|
DBDocument.is_active == True,
|
||||||
|
DBDocument.language == "email",
|
||||||
|
)
|
||||||
|
active_doc = _owner_session_filter(_email_doc_q, ctx.user).order_by(DBDocument.updated_at.desc()).first()
|
||||||
|
if active_doc:
|
||||||
|
logger.info(f"[doc-inject] found email draft by session fallback: title={active_doc.title!r}")
|
||||||
if not active_doc:
|
if not active_doc:
|
||||||
_session_doc_q = _doc_db.query(DBDocument).filter(
|
_session_doc_q = _doc_db.query(DBDocument).filter(
|
||||||
DBDocument.session_id == session,
|
DBDocument.session_id == session,
|
||||||
@@ -766,20 +872,35 @@ def setup_chat_routes(
|
|||||||
|
|
||||||
# Build disabled-tools set from frontend toggles + user privileges
|
# Build disabled-tools set from frontend toggles + user privileges
|
||||||
disabled_tools = set()
|
disabled_tools = set()
|
||||||
# Only disable bash/web_search when the caller *explicitly* set them
|
# Only disable bash when the caller *explicitly* set it to a falsy
|
||||||
# to a falsy value. When unset (None), defer to per-user privilege
|
# value. When unset (None), defer to per-user privilege checks below.
|
||||||
# checks below — this lets admins with can_use_bash=True use bash
|
# Web search is per-turn opt-in: either the chat pre-search setting
|
||||||
# by default without having to send allow_bash in every request.
|
# (`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":
|
if allow_bash is not None and str(allow_bash).lower() != "true":
|
||||||
disabled_tools.add("bash")
|
disabled_tools.add("bash")
|
||||||
_explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")
|
_explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")
|
||||||
if (
|
if is_web_search_explicitly_denied(allow_web_search) or not _search_enabled:
|
||||||
allow_web_search is not None
|
disabled_tools.update(WEB_TOOL_NAMES)
|
||||||
and str(allow_web_search).lower() != "true"
|
if _explicit_web_intent:
|
||||||
and not _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
|
||||||
disabled_tools.add("web_search")
|
# when the request's explicit web setting enabled them.
|
||||||
disabled_tools.add("web_fetch")
|
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
|
# Nobody/incognito mode: deny tools that would expose the user's
|
||||||
# persistent memory, past chats, or other identity-linked data.
|
# persistent memory, past chats, or other identity-linked data.
|
||||||
@@ -790,19 +911,19 @@ def setup_chat_routes(
|
|||||||
"manage_skills", # skill presets tied to user
|
"manage_skills", # skill presets tied to user
|
||||||
})
|
})
|
||||||
|
|
||||||
# Active email reader open → strip the tools that let the agent
|
# Active email reader open → strip the tools that let the agent drift
|
||||||
# "drift" to a new compose: create_document (writes a fake email-
|
# away from the visible email or skip review. The only allowed compose
|
||||||
# shaped .md file) and send_email (sends fresh to a recipient the
|
# path is ui_control open_email_reply, which opens the same draft editor
|
||||||
# agent invented). With those gone, the only paths left for "write
|
# as the Reply button with the generated body pre-filled. This prevents
|
||||||
# email saying X" are ui_control open_email_reply (draft) and
|
# the model from falling back to direct SMTP when it botches a draft
|
||||||
# reply_to_email (immediate send) — both of which use the open
|
# call, and prevents fake email-shaped documents.
|
||||||
# email's UID. Code-level enforcement instead of relying on a
|
|
||||||
# prompt rule the model can ignore.
|
|
||||||
if active_email_ctx and active_email_ctx.get("uid"):
|
if active_email_ctx and active_email_ctx.get("uid"):
|
||||||
disabled_tools.update({
|
disabled_tools.update({
|
||||||
"create_document",
|
"create_document",
|
||||||
"send_email",
|
"send_email",
|
||||||
|
"reply_to_email",
|
||||||
"mcp__email__send_email",
|
"mcp__email__send_email",
|
||||||
|
"mcp__email__reply_to_email",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Enforce per-user privileges
|
# Enforce per-user privileges
|
||||||
@@ -830,10 +951,6 @@ def setup_chat_routes(
|
|||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
_global_disabled = get_setting("disabled_tools", [])
|
_global_disabled = get_setting("disabled_tools", [])
|
||||||
if _global_disabled and isinstance(_global_disabled, list):
|
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
|
# Light auto-escalation: the user is in chat mode and just expressed a
|
||||||
@@ -1048,13 +1165,16 @@ def setup_chat_routes(
|
|||||||
_active_streams.pop(session, None)
|
_active_streams.pop(session, None)
|
||||||
return
|
return
|
||||||
|
|
||||||
messages = ctx.messages
|
messages = _ensure_current_request_is_latest_user(ctx.messages, message)
|
||||||
|
|
||||||
# Auto-compact notification
|
# Auto-compact notification
|
||||||
if ctx.was_compacted:
|
if ctx.was_compacted:
|
||||||
yield f"data: {json.dumps({'type': 'compacted', 'context_length': ctx.context_length})}\n\n"
|
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 = ""
|
full_response = ""
|
||||||
|
thinking_response = ""
|
||||||
last_metrics = None
|
last_metrics = None
|
||||||
|
|
||||||
# Configured fallback chain for the default chat model. Tried in
|
# Configured fallback chain for the default chat model. Tried in
|
||||||
@@ -1144,7 +1264,9 @@ def setup_chat_routes(
|
|||||||
# Forward them so the client can show a thinking
|
# Forward them so the client can show a thinking
|
||||||
# indicator, but don't fold them into the saved
|
# indicator, but don't fold them into the saved
|
||||||
# reply (mirrors the rewrite path below).
|
# reply (mirrors the rewrite path below).
|
||||||
if not data.get("thinking"):
|
if data.get("thinking"):
|
||||||
|
thinking_response += data["delta"]
|
||||||
|
else:
|
||||||
full_response += data["delta"]
|
full_response += data["delta"]
|
||||||
_stream_set(session, partial=full_response)
|
_stream_set(session, partial=full_response)
|
||||||
yield chunk
|
yield chunk
|
||||||
@@ -1164,6 +1286,12 @@ def setup_chat_routes(
|
|||||||
_reported_model = last_metrics.get("model")
|
_reported_model = last_metrics.get("model")
|
||||||
last_metrics["requested_model"] = _requested_model
|
last_metrics["requested_model"] = _requested_model
|
||||||
last_metrics["model"] = _reported_model or _actual_model or _answered_by 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
|
||||||
if ctx.context_length and last_metrics.get("input_tokens"):
|
if ctx.context_length and last_metrics.get("input_tokens"):
|
||||||
pct = min(round((last_metrics["input_tokens"] / ctx.context_length) * 100, 1), 100.0)
|
pct = min(round((last_metrics["input_tokens"] / ctx.context_length) * 100, 1), 100.0)
|
||||||
last_metrics["context_percent"] = pct
|
last_metrics["context_percent"] = pct
|
||||||
@@ -1206,8 +1334,11 @@ def setup_chat_routes(
|
|||||||
}
|
}
|
||||||
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
|
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
|
||||||
if full_response:
|
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(
|
_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,
|
character_name=ctx.preset.character_name,
|
||||||
web_sources=web_sources,
|
web_sources=web_sources,
|
||||||
rag_sources=ctx.rag_sources,
|
rag_sources=ctx.rag_sources,
|
||||||
@@ -1220,7 +1351,7 @@ def setup_chat_routes(
|
|||||||
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
|
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
|
||||||
run_post_response_tasks(
|
run_post_response_tasks(
|
||||||
sess, session_manager, session, message, full_response,
|
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,
|
incognito=incognito, compare_mode=compare_mode,
|
||||||
character_name=ctx.preset.character_name,
|
character_name=ctx.preset.character_name,
|
||||||
owner=_user,
|
owner=_user,
|
||||||
@@ -1272,8 +1403,8 @@ def setup_chat_routes(
|
|||||||
_max_rounds = max(1, min(_max_rounds, 200))
|
_max_rounds = max(1, min(_max_rounds, 200))
|
||||||
|
|
||||||
_forced_tools = None
|
_forced_tools = None
|
||||||
if allow_web_search is not None and str(allow_web_search).lower() == "true":
|
if _search_enabled:
|
||||||
_forced_tools = {"web_search", "web_fetch"}
|
_forced_tools = set(WEB_TOOL_NAMES)
|
||||||
|
|
||||||
async for chunk in stream_agent_loop(
|
async for chunk in stream_agent_loop(
|
||||||
sess.endpoint_url,
|
sess.endpoint_url,
|
||||||
@@ -1306,7 +1437,9 @@ def setup_chat_routes(
|
|||||||
# Reasoning tokens arrive flagged thinking:true.
|
# Reasoning tokens arrive flagged thinking:true.
|
||||||
# Forward them for the live indicator, but keep
|
# Forward them for the live indicator, but keep
|
||||||
# them out of the saved reply (same as chat mode).
|
# 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"]
|
full_response += data["delta"]
|
||||||
_stream_set(session, partial=full_response)
|
_stream_set(session, partial=full_response)
|
||||||
yield chunk
|
yield chunk
|
||||||
@@ -1344,15 +1477,26 @@ def setup_chat_routes(
|
|||||||
_reported_model = last_metrics.get("model")
|
_reported_model = last_metrics.get("model")
|
||||||
last_metrics["requested_model"] = last_metrics.get("requested_model") or _requested_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
|
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'
|
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
yield chunk
|
yield chunk
|
||||||
elif chunk.startswith("event: "):
|
elif chunk.startswith("event: "):
|
||||||
yield chunk
|
yield chunk
|
||||||
elif chunk == "data: [DONE]\n\n":
|
elif chunk == "data: [DONE]\n\n":
|
||||||
if full_response:
|
_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(
|
_saved_id = save_assistant_response(
|
||||||
sess, session_manager, session, full_response, last_metrics,
|
sess, session_manager, session, _response_to_save, _metrics_to_save,
|
||||||
character_name=ctx.preset.character_name,
|
character_name=ctx.preset.character_name,
|
||||||
web_sources=web_sources,
|
web_sources=web_sources,
|
||||||
rag_sources=ctx.rag_sources,
|
rag_sources=ctx.rag_sources,
|
||||||
@@ -1362,8 +1506,8 @@ def setup_chat_routes(
|
|||||||
if _saved_id:
|
if _saved_id:
|
||||||
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
|
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
|
||||||
run_post_response_tasks(
|
run_post_response_tasks(
|
||||||
sess, session_manager, session, message, full_response,
|
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,
|
incognito=incognito, compare_mode=compare_mode,
|
||||||
character_name=ctx.preset.character_name,
|
character_name=ctx.preset.character_name,
|
||||||
agent_rounds=_agent_rounds,
|
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 @@
|
|||||||
"""
|
"""Backward-compat shim — canonical location is routes/contacts/contacts_routes.py.
|
||||||
contacts_routes.py
|
|
||||||
|
|
||||||
CardDAV contacts integration. Reads from local Radicale, supports
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
search and adding new contacts.
|
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 sys as _sys
|
||||||
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 routes.contacts import contacts_routes as _canonical # noqa: F401
|
||||||
from fastapi import APIRouter, Query, Depends, Response, HTTPException
|
|
||||||
from typing import List, Dict, Optional
|
|
||||||
|
|
||||||
from core.middleware import require_admin
|
_sys.modules[__name__] = _canonical
|
||||||
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
|
|
||||||
|
|||||||
+64
-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.is_file(): nf += 1; sz += f.stat().st_size",
|
||||||
" if f.name.endswith('.incomplete'): ic = True",
|
" if f.name.endswith('.incomplete'): ic = True",
|
||||||
" snap = os.path.join(cache, d, 'snapshots')",
|
" snap = os.path.join(cache, d, 'snapshots')",
|
||||||
" # Windows HF cache stores files directly in snapshots/; blobs/ may be empty.",
|
" def snapshot_size():",
|
||||||
" # Fallback: scan snapshots for real files when blobs yielded nothing.",
|
" total, count, incomplete = 0, 0, False",
|
||||||
" if sz == 0 and os.path.isdir(snap):",
|
" seen_real = set()",
|
||||||
" for sd in os.listdir(snap):",
|
" for sd in os.listdir(snap):",
|
||||||
" sf = os.path.join(snap, sd)",
|
" sf = os.path.join(snap, sd)",
|
||||||
" if not os.path.isdir(sf): continue",
|
" if not os.path.isdir(sf): continue",
|
||||||
" for f in os.scandir(sf):",
|
" for root, dirs, fns in safe_walk(sf):",
|
||||||
" if f.is_file(): nf += 1; sz += f.stat().st_size",
|
" for fn in fns:",
|
||||||
" if f.name.endswith('.incomplete'): ic = True",
|
" 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 = []",
|
" is_diffusion = False; gguf_files = []",
|
||||||
" if os.path.isdir(snap):",
|
" if os.path.isdir(snap):",
|
||||||
" for sd in os.listdir(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')",
|
" add('/app/.cache/huggingface/hub')",
|
||||||
f" add({add_hf_cache!r})" if add_hf_cache else "",
|
f" add({add_hf_cache!r})" if add_hf_cache else "",
|
||||||
" return candidates",
|
" 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):",
|
"def scan_dir(p):",
|
||||||
|
" p = normalize_model_dir(p)",
|
||||||
" if not os.path.isdir(p) or not safe_path(p): return",
|
" if not os.path.isdir(p) or not safe_path(p): return",
|
||||||
" for d in sorted(os.listdir(p)):",
|
" for d in sorted(os.listdir(p)):",
|
||||||
" if d.startswith('.'): continue",
|
" if d.startswith('.'): continue",
|
||||||
@@ -541,7 +567,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
|||||||
"scan_ollama()",
|
"scan_ollama()",
|
||||||
]
|
]
|
||||||
for model_dir in model_dirs or []:
|
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))")
|
lines.append("print(json.dumps(models))")
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
@@ -558,6 +584,18 @@ def _bash_squote(v: str) -> str:
|
|||||||
return v.replace("'", "'\\''")
|
return v.replace("'", "'\\''")
|
||||||
|
|
||||||
|
|
||||||
|
# Shown by generated runner scripts when the ollama binary is missing on the
|
||||||
|
# target host. Must stay free of backticks/$( ) and be emitted single-quoted:
|
||||||
|
# an earlier version wrapped the install one-liner in backticks inside a
|
||||||
|
# double-quoted echo, which bash executed as command substitution and ran the
|
||||||
|
# system-wide installer (including on remote SSH hosts) instead of printing
|
||||||
|
# the hint.
|
||||||
|
OLLAMA_MISSING_HINT = (
|
||||||
|
"ERROR: Ollama not found on this server. Install it from "
|
||||||
|
"https://ollama.com/download or run: curl -fsSL https://ollama.com/install.sh | sh"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Allow-list of binaries permitted as the leading token of `req.cmd` for /api/model/serve.
|
# Allow-list of binaries permitted as the leading token of `req.cmd` for /api/model/serve.
|
||||||
# Anything else is rejected before the cmd is interpolated into a tmux/PowerShell wrapper.
|
# Anything else is rejected before the cmd is interpolated into a tmux/PowerShell wrapper.
|
||||||
_SERVE_CMD_ALLOWLIST = {
|
_SERVE_CMD_ALLOWLIST = {
|
||||||
@@ -1261,13 +1299,15 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
|||||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|"
|
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
|
||||||
r"(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|"
|
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",
|
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": "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"},
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1275,6 +1315,19 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
|||||||
"SGLang is not installed or not in PATH on this server.",
|
"SGLang is not installed or not in PATH on this server.",
|
||||||
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
[{"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
|
# System build deps come BEFORE the generic llama.cpp catch-all so
|
||||||
# cmake / build-essential / git missing → a specific OS-package
|
# cmake / build-essential / git missing → a specific OS-package
|
||||||
# remediation instead of "install llama-cpp-python[server]" (which
|
# remediation instead of "install llama-cpp-python[server]" (which
|
||||||
|
|||||||
+1008
-123
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ class DocumentCreate(BaseModel):
|
|||||||
class DocumentUpdate(BaseModel):
|
class DocumentUpdate(BaseModel):
|
||||||
content: str
|
content: str
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
|
force_version: bool = False
|
||||||
|
|
||||||
class DocumentPatch(BaseModel):
|
class DocumentPatch(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
|
|||||||
+90
-16
@@ -54,6 +54,18 @@ def _library_language_for_document(doc: Document) -> str:
|
|||||||
return doc.language or "text"
|
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 (
|
from routes.document_helpers import (
|
||||||
DocumentCreate, DocumentUpdate, DocumentPatch,
|
DocumentCreate, DocumentUpdate, DocumentPatch,
|
||||||
_doc_to_dict, _version_to_dict,
|
_doc_to_dict, _version_to_dict,
|
||||||
@@ -99,24 +111,62 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
# the existing lenient path.
|
# the existing lenient path.
|
||||||
session = _get_session_or_404(db, req.session_id, user)
|
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
|
# If no language was supplied (e.g. cloning a doc whose language
|
||||||
# was never set), detect it from the content rather than storing
|
# was never set), detect it from the content rather than storing
|
||||||
# NULL — which made the editor fall back to plain text. Defaults
|
# NULL — which made the editor fall back to plain text. Defaults
|
||||||
# to markdown for prose.
|
# to markdown for prose.
|
||||||
language = req.language
|
language = req.language
|
||||||
if not 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)
|
language = _sniff_doc_language(req.content)
|
||||||
else:
|
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):
|
if _looks_like_email_document(req.content, req.title):
|
||||||
language = "email"
|
language = "email"
|
||||||
|
|
||||||
_assert_pdf_marker_upload_owned(request, req.content, user, upload_handler)
|
_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(
|
doc = Document(
|
||||||
id=doc_id,
|
id=doc_id,
|
||||||
session_id=req.session_id,
|
session_id=req.session_id,
|
||||||
@@ -570,11 +620,23 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
raise HTTPException(404, "Document not found")
|
raise HTTPException(404, "Document not found")
|
||||||
_verify_doc_owner(db, doc, user)
|
_verify_doc_owner(db, doc, user)
|
||||||
|
|
||||||
# Skip if content is identical
|
incoming_content = req.content
|
||||||
if doc.current_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 == incoming_content and not req.force_version:
|
||||||
return _doc_to_dict(doc)
|
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
|
# Check if we can coalesce with the latest version
|
||||||
latest_ver = db.query(DocumentVersion).filter(
|
latest_ver = db.query(DocumentVersion).filter(
|
||||||
@@ -583,14 +645,14 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
coalesced = False
|
coalesced = False
|
||||||
if latest_ver and latest_ver.source == "user":
|
if latest_ver and latest_ver.source == "user" and not req.force_version:
|
||||||
ver_time = latest_ver.created_at
|
ver_time = latest_ver.created_at
|
||||||
if ver_time.tzinfo is None:
|
if ver_time.tzinfo is None:
|
||||||
ver_time = ver_time.replace(tzinfo=timezone.utc)
|
ver_time = ver_time.replace(tzinfo=timezone.utc)
|
||||||
age = (now - ver_time).total_seconds()
|
age = (now - ver_time).total_seconds()
|
||||||
if age < VERSION_COALESCE_SECONDS:
|
if age < VERSION_COALESCE_SECONDS:
|
||||||
# Update the existing version in-place
|
# Update the existing version in-place
|
||||||
latest_ver.content = req.content
|
latest_ver.content = incoming_content
|
||||||
latest_ver.created_at = now
|
latest_ver.created_at = now
|
||||||
if req.summary:
|
if req.summary:
|
||||||
latest_ver.summary = req.summary
|
latest_ver.summary = req.summary
|
||||||
@@ -602,14 +664,14 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
document_id=doc_id,
|
document_id=doc_id,
|
||||||
version_number=new_ver,
|
version_number=new_ver,
|
||||||
content=req.content,
|
content=incoming_content,
|
||||||
summary=req.summary or "Manual edit",
|
summary=req.summary or "Manual edit",
|
||||||
source="user",
|
source="user",
|
||||||
)
|
)
|
||||||
doc.version_count = new_ver
|
doc.version_count = new_ver
|
||||||
db.add(ver)
|
db.add(ver)
|
||||||
|
|
||||||
doc.current_content = req.content
|
doc.current_content = incoming_content
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
return _doc_to_dict(doc)
|
return _doc_to_dict(doc)
|
||||||
@@ -799,10 +861,26 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
from src.document_actions import _JUNK_TITLES
|
from src.document_actions import _JUNK_TITLES
|
||||||
|
|
||||||
to_delete = []
|
to_delete = []
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
|
created = doc.created_at
|
||||||
|
if created and created.tzinfo is None:
|
||||||
|
created = created.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Skip freshly created documents to avoid deleting them while the user is actively editing
|
||||||
|
if created and (now - created).total_seconds() < 900: # 15 minutes
|
||||||
|
continue
|
||||||
|
|
||||||
content = (doc.current_content or "").strip()
|
content = (doc.current_content or "").strip()
|
||||||
title_raw = (doc.title or "").strip()
|
title_raw = (doc.title or "").strip()
|
||||||
title = title_raw.lower()
|
title = title_raw.lower()
|
||||||
|
is_fresh_empty = (
|
||||||
|
not content
|
||||||
|
and created is not None
|
||||||
|
and (now - created).total_seconds() < 1800
|
||||||
|
)
|
||||||
|
if is_fresh_empty:
|
||||||
|
continue
|
||||||
|
|
||||||
# Strip markdown noise to get a "real" character count
|
# Strip markdown noise to get a "real" character count
|
||||||
stripped = _re.sub(r"^#{1,6}\s+", "", content, flags=_re.MULTILINE)
|
stripped = _re.sub(r"^#{1,6}\s+", "", content, flags=_re.MULTILINE)
|
||||||
@@ -837,10 +915,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
to_delete.append(doc); deleted += 1; continue
|
to_delete.append(doc); deleted += 1; continue
|
||||||
if title in _JUNK_TITLES:
|
if title in _JUNK_TITLES:
|
||||||
to_delete.append(doc); deleted += 1; continue
|
to_delete.append(doc); deleted += 1; continue
|
||||||
if real_len < 30:
|
|
||||||
to_delete.append(doc); deleted += 1; continue
|
|
||||||
if "\n" not in content and real_len < 50:
|
|
||||||
to_delete.append(doc); deleted += 1; continue
|
|
||||||
|
|
||||||
# Fix empty or placeholder titles on survivors
|
# Fix empty or placeholder titles on survivors
|
||||||
if not title_raw or title_raw == "Untitled":
|
if not title_raw or title_raw == "Untitled":
|
||||||
|
|||||||
+162
-17
@@ -349,7 +349,7 @@ def _assert_owns_account(account_id: str, owner: str) -> None:
|
|||||||
row = db.query(_EA).filter(_EA.id == account_id).first()
|
row = db.query(_EA).filter(_EA.id == account_id).first()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(404, "Account not found")
|
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.
|
# Treat as 404 (not 403) so we don't leak existence.
|
||||||
raise HTTPException(404, "Account not found")
|
raise HTTPException(404, "Account not found")
|
||||||
finally:
|
finally:
|
||||||
@@ -362,6 +362,26 @@ def _assert_owns_account(account_id: str, owner: str) -> None:
|
|||||||
logger.error(f"Account-owner check failed: {e}")
|
logger.error(f"Account-owner check failed: {e}")
|
||||||
raise HTTPException(503, "Account check failed")
|
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:
|
def _q(name: str) -> str:
|
||||||
"""Quote an IMAP mailbox name. Defensive: escapes `\\` and `"` and wraps
|
"""Quote an IMAP mailbox name. Defensive: escapes `\\` and `"` and wraps
|
||||||
in double quotes so user-supplied folder names with spaces or quotes can't
|
in double quotes so user-supplied folder names with spaces or quotes can't
|
||||||
@@ -424,12 +444,19 @@ SCHEDULED_DB = Path(SCHEDULED_EMAILS_DB)
|
|||||||
OWNER_SCOPED_EMAIL_CACHE_TABLES = {
|
OWNER_SCOPED_EMAIL_CACHE_TABLES = {
|
||||||
"email_summaries",
|
"email_summaries",
|
||||||
"email_ai_replies",
|
"email_ai_replies",
|
||||||
|
"email_translations",
|
||||||
"email_calendar_extractions",
|
"email_calendar_extractions",
|
||||||
"email_urgency_alerts",
|
"email_urgency_alerts",
|
||||||
"sender_signatures",
|
"sender_signatures",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def email_translation_body_hash(body: str) -> str:
|
||||||
|
import hashlib as _hashlib
|
||||||
|
normalized = (body or "").strip()
|
||||||
|
return _hashlib.sha256(normalized.encode("utf-8", errors="ignore")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def _email_cache_owner_clause(owner: str = "") -> tuple[str, tuple[str, ...]]:
|
def _email_cache_owner_clause(owner: str = "") -> tuple[str, tuple[str, ...]]:
|
||||||
owner = (owner or "").strip()
|
owner = (owner or "").strip()
|
||||||
if owner:
|
if owner:
|
||||||
@@ -437,14 +464,34 @@ def _email_cache_owner_clause(owner: str = "") -> tuple[str, tuple[str, ...]]:
|
|||||||
return "(owner = '' OR owner IS NULL)", ()
|
return "(owner = '' OR owner IS NULL)", ()
|
||||||
|
|
||||||
|
|
||||||
def _ensure_owner_scoped_email_cache_table(conn, table: str, create_sql: str, columns: list[str]):
|
def _ensure_owner_scoped_email_cache_table(
|
||||||
|
conn,
|
||||||
|
table: str,
|
||||||
|
create_sql: str,
|
||||||
|
columns: list[str],
|
||||||
|
pk_columns: list[str] | None = None,
|
||||||
|
):
|
||||||
"""Rebuild legacy Message-ID-only cache tables with owner in the PK."""
|
"""Rebuild legacy Message-ID-only cache tables with owner in the PK."""
|
||||||
|
desired_pk_cols = pk_columns or ["message_id", "owner"]
|
||||||
conn.execute(create_sql)
|
conn.execute(create_sql)
|
||||||
try:
|
try:
|
||||||
info = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
info = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||||
cols = [r[1] for r in info]
|
cols = [r[1] for r in info]
|
||||||
pk_cols = [r[1] for r in sorted((r for r in info if r[5]), key=lambda r: r[5])]
|
pk_cols = [r[1] for r in sorted((r for r in info if r[5]), key=lambda r: r[5])]
|
||||||
if "owner" in cols and pk_cols == ["message_id", "owner"]:
|
for col in columns:
|
||||||
|
if col not in cols:
|
||||||
|
if col == "owner":
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN owner TEXT DEFAULT ''")
|
||||||
|
elif col in {"event_uids"}:
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} TEXT DEFAULT '[]'")
|
||||||
|
elif col.startswith("has_") or col.endswith("_created") or col.endswith("_count"):
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} INTEGER DEFAULT 0")
|
||||||
|
elif col == "created_at":
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} TEXT DEFAULT ''")
|
||||||
|
else:
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} TEXT")
|
||||||
|
cols.append(col)
|
||||||
|
if "owner" in cols and pk_cols == desired_pk_cols:
|
||||||
return
|
return
|
||||||
|
|
||||||
conn.execute(f"ALTER TABLE {table} RENAME TO {table}__old")
|
conn.execute(f"ALTER TABLE {table} RENAME TO {table}__old")
|
||||||
@@ -577,6 +624,25 @@ def _init_scheduled_db():
|
|||||||
PRIMARY KEY (message_id, owner)
|
PRIMARY KEY (message_id, owner)
|
||||||
)
|
)
|
||||||
""", ["message_id", "owner", "uid", "folder", "reply", "model_used", "created_at"])
|
""", ["message_id", "owner", "uid", "folder", "reply", "model_used", "created_at"])
|
||||||
|
_ensure_owner_scoped_email_cache_table(conn, "email_translations", """
|
||||||
|
CREATE TABLE IF NOT EXISTS email_translations (
|
||||||
|
body_hash TEXT,
|
||||||
|
owner TEXT DEFAULT '',
|
||||||
|
target_language TEXT DEFAULT 'English',
|
||||||
|
uid TEXT,
|
||||||
|
folder TEXT,
|
||||||
|
subject TEXT,
|
||||||
|
sender TEXT,
|
||||||
|
translation TEXT,
|
||||||
|
same_language INTEGER DEFAULT 0,
|
||||||
|
model_used TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (body_hash, owner, target_language)
|
||||||
|
)
|
||||||
|
""", [
|
||||||
|
"body_hash", "owner", "target_language", "uid", "folder", "subject", "sender",
|
||||||
|
"translation", "same_language", "model_used", "created_at",
|
||||||
|
], ["body_hash", "owner", "target_language"])
|
||||||
# Email tags / spam classification cache. SECURITY: keyed by
|
# Email tags / spam classification cache. SECURITY: keyed by
|
||||||
# (message_id, owner) because Message-IDs are GLOBAL (a newsletter goes
|
# (message_id, owner) because Message-IDs are GLOBAL (a newsletter goes
|
||||||
# to many users with the same Message-ID). Without owner-scoping, a
|
# to many users with the same Message-ID). Without owner-scoping, a
|
||||||
@@ -586,6 +652,7 @@ def _init_scheduled_db():
|
|||||||
CREATE TABLE IF NOT EXISTS email_tags (
|
CREATE TABLE IF NOT EXISTS email_tags (
|
||||||
message_id TEXT,
|
message_id TEXT,
|
||||||
owner TEXT DEFAULT '',
|
owner TEXT DEFAULT '',
|
||||||
|
account_id TEXT DEFAULT '',
|
||||||
uid TEXT,
|
uid TEXT,
|
||||||
folder TEXT,
|
folder TEXT,
|
||||||
subject TEXT,
|
subject TEXT,
|
||||||
@@ -596,7 +663,7 @@ def _init_scheduled_db():
|
|||||||
moved_to TEXT,
|
moved_to TEXT,
|
||||||
model_used TEXT,
|
model_used TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (message_id, owner)
|
PRIMARY KEY (message_id, owner, account_id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
# Backfill migration: older installs created the table with
|
# Backfill migration: older installs created the table with
|
||||||
@@ -604,28 +671,35 @@ def _init_scheduled_db():
|
|||||||
# promote it into the PK by rebuild-copy-swap (SQLite can't ALTER PK).
|
# promote it into the PK by rebuild-copy-swap (SQLite can't ALTER PK).
|
||||||
try:
|
try:
|
||||||
_cols = [r[1] for r in conn.execute("PRAGMA table_info(email_tags)")]
|
_cols = [r[1] for r in conn.execute("PRAGMA table_info(email_tags)")]
|
||||||
|
_pk_cols = [r[1] for r in sorted(conn.execute("PRAGMA table_info(email_tags)").fetchall(), key=lambda row: row[5] or 99) if r[5]]
|
||||||
if "owner" not in _cols:
|
if "owner" not in _cols:
|
||||||
# Add the column first so reads/writes don't break mid-migration.
|
|
||||||
conn.execute("ALTER TABLE email_tags ADD COLUMN owner TEXT DEFAULT ''")
|
conn.execute("ALTER TABLE email_tags ADD COLUMN owner TEXT DEFAULT ''")
|
||||||
# Rebuild with composite PK. Existing rows get owner='' (legacy
|
_cols.append("owner")
|
||||||
# single-user); the urgency scanner will overwrite as it
|
if "account_id" not in _cols:
|
||||||
# re-classifies. No data loss.
|
conn.execute("ALTER TABLE email_tags ADD COLUMN account_id TEXT DEFAULT ''")
|
||||||
|
_cols.append("account_id")
|
||||||
|
if _pk_cols != ["message_id", "owner", "account_id"]:
|
||||||
|
# Rebuild with account-aware composite PK. Existing rows get
|
||||||
|
# account_id='' and are still readable as legacy fallback rows;
|
||||||
|
# fresh task runs write exact account ids and no longer block each
|
||||||
|
# other when two accounts share a Message-ID.
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS email_tags__new (
|
CREATE TABLE IF NOT EXISTS email_tags__new (
|
||||||
message_id TEXT,
|
message_id TEXT,
|
||||||
owner TEXT DEFAULT '',
|
owner TEXT DEFAULT '',
|
||||||
|
account_id TEXT DEFAULT '',
|
||||||
uid TEXT, folder TEXT, subject TEXT, sender TEXT,
|
uid TEXT, folder TEXT, subject TEXT, sender TEXT,
|
||||||
tags TEXT, spam_verdict INTEGER DEFAULT 0,
|
tags TEXT, spam_verdict INTEGER DEFAULT 0,
|
||||||
spam_reason TEXT, moved_to TEXT, model_used TEXT,
|
spam_reason TEXT, moved_to TEXT, model_used TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (message_id, owner)
|
PRIMARY KEY (message_id, owner, account_id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT OR IGNORE INTO email_tags__new
|
INSERT OR IGNORE INTO email_tags__new
|
||||||
(message_id, owner, uid, folder, subject, sender, tags,
|
(message_id, owner, account_id, uid, folder, subject, sender, tags,
|
||||||
spam_verdict, spam_reason, moved_to, model_used, created_at)
|
spam_verdict, spam_reason, moved_to, model_used, created_at)
|
||||||
SELECT message_id, COALESCE(owner, ''), uid, folder, subject,
|
SELECT message_id, COALESCE(owner, ''), COALESCE(account_id, ''), uid, folder, subject,
|
||||||
sender, tags, spam_verdict, spam_reason, moved_to,
|
sender, tags, spam_verdict, spam_reason, moved_to,
|
||||||
model_used, created_at
|
model_used, created_at
|
||||||
FROM email_tags
|
FROM email_tags
|
||||||
@@ -641,11 +715,12 @@ def _init_scheduled_db():
|
|||||||
message_id TEXT,
|
message_id TEXT,
|
||||||
owner TEXT DEFAULT '',
|
owner TEXT DEFAULT '',
|
||||||
uid TEXT,
|
uid TEXT,
|
||||||
|
event_uids TEXT DEFAULT '[]',
|
||||||
events_created INTEGER DEFAULT 0,
|
events_created INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (message_id, owner)
|
PRIMARY KEY (message_id, owner)
|
||||||
)
|
)
|
||||||
""", ["message_id", "owner", "uid", "events_created", "created_at"])
|
""", ["message_id", "owner", "uid", "event_uids", "events_created", "created_at"])
|
||||||
_ensure_owner_scoped_email_cache_table(conn, "email_urgency_alerts", """
|
_ensure_owner_scoped_email_cache_table(conn, "email_urgency_alerts", """
|
||||||
CREATE TABLE IF NOT EXISTS email_urgency_alerts (
|
CREATE TABLE IF NOT EXISTS email_urgency_alerts (
|
||||||
message_id TEXT,
|
message_id TEXT,
|
||||||
@@ -671,6 +746,64 @@ def _init_scheduled_db():
|
|||||||
PRIMARY KEY (owner, account_key, folder, message_key)
|
PRIMARY KEY (owner, account_key, folder, message_key)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS email_message_index (
|
||||||
|
owner TEXT NOT NULL DEFAULT '',
|
||||||
|
account_key TEXT NOT NULL DEFAULT '',
|
||||||
|
folder TEXT NOT NULL,
|
||||||
|
uid TEXT NOT NULL,
|
||||||
|
message_id TEXT,
|
||||||
|
subject TEXT,
|
||||||
|
from_name TEXT,
|
||||||
|
from_address TEXT,
|
||||||
|
to_text TEXT,
|
||||||
|
cc_text TEXT,
|
||||||
|
date_iso TEXT,
|
||||||
|
date_display TEXT,
|
||||||
|
date_epoch REAL DEFAULT 0,
|
||||||
|
size INTEGER DEFAULT 0,
|
||||||
|
flags TEXT DEFAULT '',
|
||||||
|
has_attachments INTEGER DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (owner, account_key, folder, uid)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_email_message_index_folder_date
|
||||||
|
ON email_message_index(owner, account_key, folder, date_epoch DESC)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_email_message_index_message_id
|
||||||
|
ON email_message_index(owner, account_key, message_id)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS email_body_preview_cache (
|
||||||
|
owner TEXT NOT NULL DEFAULT '',
|
||||||
|
account_key TEXT NOT NULL DEFAULT '',
|
||||||
|
folder TEXT NOT NULL,
|
||||||
|
uid TEXT NOT NULL,
|
||||||
|
message_id TEXT,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (owner, account_key, folder, uid)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_email_body_preview_message_id
|
||||||
|
ON email_body_preview_cache(owner, account_key, message_id)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS email_attachment_metadata_cache (
|
||||||
|
owner TEXT NOT NULL DEFAULT '',
|
||||||
|
account_key TEXT NOT NULL DEFAULT '',
|
||||||
|
folder TEXT NOT NULL,
|
||||||
|
uid TEXT NOT NULL,
|
||||||
|
message_id TEXT,
|
||||||
|
attachments_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (owner, account_key, folder, uid)
|
||||||
|
)
|
||||||
|
""")
|
||||||
# Boundary cache — LLM-detected sig/quote start positions in the body.
|
# Boundary cache — LLM-detected sig/quote start positions in the body.
|
||||||
# Stored as char offsets (-1 = no boundary found). Once cached, the
|
# Stored as char offsets (-1 = no boundary found). Once cached, the
|
||||||
# client uses these to fold without ever re-calling the LLM.
|
# client uses these to fold without ever re-calling the LLM.
|
||||||
@@ -790,12 +923,13 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
|
|||||||
try:
|
try:
|
||||||
if account_id:
|
if account_id:
|
||||||
row = db.query(_EA).filter(_EA.id == account_id, _EA.enabled == True).first() # noqa: E712
|
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
|
# not-found rather than silently serving it. This is a defense
|
||||||
# in depth — `require_owner` already calls `_assert_owns_account`
|
# in depth — `require_owner` already calls `_assert_owns_account`
|
||||||
# for query-param account_ids, but other callers (cookbook
|
# for query-param account_ids, but other callers (cookbook
|
||||||
# rules, scheduled poller) may not.
|
# rules, scheduled poller) may not. Ownerless legacy rows are
|
||||||
if row is not None and owner and row.owner and row.owner != owner:
|
# 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
|
row = None
|
||||||
# Fallback path — restrict to this owner's accounts so we don't
|
# Fallback path — restrict to this owner's accounts so we don't
|
||||||
# leak another user's default mailbox to an unconfigured user.
|
# leak another user's default mailbox to an unconfigured user.
|
||||||
@@ -1160,10 +1294,15 @@ def _imap_move(uid, dest, src="INBOX", account_id: str | None = None, owner: str
|
|||||||
try:
|
try:
|
||||||
c = _imap_connect(account_id, owner=owner)
|
c = _imap_connect(account_id, owner=owner)
|
||||||
c.select(_q(src))
|
c.select(_q(src))
|
||||||
status, _ = c.copy(uid, _q(dest))
|
# Callers pass a real IMAP UID (from conn.uid("SEARCH", ...)). copy()
|
||||||
|
# and store() operate on message SEQUENCE NUMBERS, so addressing them
|
||||||
|
# with a UID moved/deleted the wrong message (or silently no-oped when
|
||||||
|
# the UID exceeded the message count). Use the UID commands, matching
|
||||||
|
# the move/delete path in email_routes.py.
|
||||||
|
status, _ = c.uid("COPY", uid, _q(dest))
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
return False
|
return False
|
||||||
c.store(uid, "+FLAGS", "\\Deleted")
|
c.uid("STORE", uid, "+FLAGS", "\\Deleted")
|
||||||
c.expunge()
|
c.expunge()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1276,12 +1415,14 @@ def _list_attachments_from_msg(msg):
|
|||||||
except Exception:
|
except Exception:
|
||||||
payload = b""
|
payload = b""
|
||||||
size = len(payload) if payload is not None else 0
|
size = len(payload) if payload is not None else 0
|
||||||
|
content_id = (part.get("Content-ID") or "").strip().strip("<>")
|
||||||
attachments.append({
|
attachments.append({
|
||||||
"index": idx,
|
"index": idx,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"content_type": ct,
|
"content_type": ct,
|
||||||
"size": size,
|
"size": size,
|
||||||
"is_inline": "inline" in cd.lower(),
|
"is_inline": "inline" in cd.lower(),
|
||||||
|
"content_id": content_id,
|
||||||
})
|
})
|
||||||
idx += 1
|
idx += 1
|
||||||
return attachments
|
return attachments
|
||||||
@@ -1720,6 +1861,10 @@ class SendEmailRequest(BaseModel):
|
|||||||
attachments: Optional[List[str]] = None
|
attachments: Optional[List[str]] = None
|
||||||
# Which account to send from. None = default account.
|
# Which account to send from. None = default account.
|
||||||
account_id: Optional[str] = None
|
account_id: Optional[str] = None
|
||||||
|
# Source message for replies. When present, /send marks this exact message
|
||||||
|
# answered after successful delivery so it leaves undone/reply-soon views.
|
||||||
|
source_uid: Optional[str] = None
|
||||||
|
source_folder: Optional[str] = None
|
||||||
# Internal marker for Odysseus-generated mail (e.g. reminder, scheduled).
|
# Internal marker for Odysseus-generated mail (e.g. reminder, scheduled).
|
||||||
odysseus_kind: Optional[str] = None
|
odysseus_kind: Optional[str] = None
|
||||||
# If true, /send waits for SMTP + Sent append and returns the sent UID.
|
# If true, /send waits for SMTP + Sent append and returns the sent UID.
|
||||||
|
|||||||
+143
-92
@@ -29,7 +29,7 @@ from datetime import datetime
|
|||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
from src.llm_core import llm_call_async
|
from src.task_endpoint import resolve_task_candidates, task_llm_call_async
|
||||||
|
|
||||||
from routes.email_helpers import (
|
from routes.email_helpers import (
|
||||||
_strip_think, _extract_reply, _apply_email_style_mechanics, _load_settings, _save_settings, _get_email_config,
|
_strip_think, _extract_reply, _apply_email_style_mechanics, _load_settings, _save_settings, _get_email_config,
|
||||||
@@ -56,6 +56,35 @@ _CAL_ACTION_ARRAY_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_array_from_text(text: str):
|
||||||
|
"""Return the last valid JSON array embedded in model output, if any."""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.MULTILINE).strip()
|
||||||
|
decoder = json.JSONDecoder()
|
||||||
|
try:
|
||||||
|
parsed = decoder.decode(cleaned)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return parsed
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Models often explain themselves and finish with `[]` or `[{"action":...}]`.
|
||||||
|
# Scan every array opener and keep the last complete JSON array, rather than
|
||||||
|
# using a greedy regex that can swallow prose containing square brackets.
|
||||||
|
last = None
|
||||||
|
for idx, ch in enumerate(cleaned):
|
||||||
|
if ch != "[":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed, _end = decoder.raw_decode(cleaned[idx:])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
last = parsed
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
def _owner_for_email_account(account_id: str | None) -> str:
|
def _owner_for_email_account(account_id: str | None) -> str:
|
||||||
if not account_id:
|
if not account_id:
|
||||||
return ""
|
return ""
|
||||||
@@ -88,6 +117,8 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
|
|||||||
do_tag: bool = False, do_spam: bool = False,
|
do_tag: bool = False, do_spam: bool = False,
|
||||||
do_calendar: bool = False,
|
do_calendar: bool = False,
|
||||||
days_back: int = 1,
|
days_back: int = 1,
|
||||||
|
account_id: str | None = None,
|
||||||
|
max_process: int | None = None,
|
||||||
progress_cb=None) -> str:
|
progress_cb=None) -> str:
|
||||||
"""One iteration of the email scan. Temporarily flips settings flags
|
"""One iteration of the email scan. Temporarily flips settings flags
|
||||||
so the existing background-loop logic runs exactly once for the requested ops."""
|
so the existing background-loop logic runs exactly once for the requested ops."""
|
||||||
@@ -102,7 +133,12 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
|
|||||||
settings["email_auto_calendar"] = bool(do_calendar)
|
settings["email_auto_calendar"] = bool(do_calendar)
|
||||||
_save_settings(settings)
|
_save_settings(settings)
|
||||||
try:
|
try:
|
||||||
return await _auto_summarize_pass(days_back=days_back, progress_cb=progress_cb)
|
return await _auto_summarize_pass(
|
||||||
|
days_back=days_back,
|
||||||
|
account_id=account_id,
|
||||||
|
max_process=max_process,
|
||||||
|
progress_cb=progress_cb,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s2 = _load_settings()
|
s2 = _load_settings()
|
||||||
for k, v in prev.items():
|
for k, v in prev.items():
|
||||||
@@ -140,7 +176,7 @@ def _latest_inbox_fallback_uids(conn, reconnect):
|
|||||||
return [], reconnect()
|
return [], reconnect()
|
||||||
|
|
||||||
|
|
||||||
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, progress_cb=None) -> str:
|
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None) -> str:
|
||||||
"""Single pass of the auto-summarize/reply scan.
|
"""Single pass of the auto-summarize/reply scan.
|
||||||
|
|
||||||
When account_id is None, iterates over every enabled account in
|
When account_id is None, iterates over every enabled account in
|
||||||
@@ -167,28 +203,41 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
|
|||||||
names = {}
|
names = {}
|
||||||
if len(ids) <= 1:
|
if len(ids) <= 1:
|
||||||
# Single-account (or zero rows — fallback to legacy settings.json lookup)
|
# Single-account (or zero rows — fallback to legacy settings.json lookup)
|
||||||
return await _auto_summarize_pass_single(days_back=days_back, account_id=(ids[0] if ids else None), progress_cb=progress_cb)
|
return await _auto_summarize_pass_single(
|
||||||
|
days_back=days_back,
|
||||||
|
account_id=(ids[0] if ids else None),
|
||||||
|
max_process=max_process,
|
||||||
|
progress_cb=progress_cb,
|
||||||
|
)
|
||||||
outs = []
|
outs = []
|
||||||
for idx, aid in enumerate(ids, start=1):
|
for idx, aid in enumerate(ids, start=1):
|
||||||
try:
|
try:
|
||||||
await _emit_progress(progress_cb, f"{names.get(aid, aid[:8])}: starting ({idx}/{len(ids)})")
|
await _emit_progress(progress_cb, f"{names.get(aid, aid[:8])}: starting ({idx}/{len(ids)})")
|
||||||
result = await _auto_summarize_pass_single(days_back=days_back, account_id=aid, progress_cb=progress_cb)
|
result = await _auto_summarize_pass_single(
|
||||||
|
days_back=days_back,
|
||||||
|
account_id=aid,
|
||||||
|
max_process=max_process,
|
||||||
|
progress_cb=progress_cb,
|
||||||
|
)
|
||||||
outs.append(f"[{names.get(aid, aid[:8])}] {result}")
|
outs.append(f"[{names.get(aid, aid[:8])}] {result}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"auto-summarize pass failed for account {aid}: {e}")
|
logger.warning(f"auto-summarize pass failed for account {aid}: {e}")
|
||||||
outs.append(f"[{names.get(aid, aid[:8])}] error: {e}")
|
outs.append(f"[{names.get(aid, aid[:8])}] error: {e}")
|
||||||
return "\n".join(outs)
|
return "\n".join(outs)
|
||||||
return await _auto_summarize_pass_single(days_back=days_back, account_id=account_id, progress_cb=progress_cb)
|
return await _auto_summarize_pass_single(
|
||||||
|
days_back=days_back,
|
||||||
|
account_id=account_id,
|
||||||
|
max_process=max_process,
|
||||||
|
progress_cb=progress_cb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, progress_cb=None) -> str:
|
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None) -> str:
|
||||||
"""Single pass of the auto-summarize/reply scan for ONE account.
|
"""Single pass of the auto-summarize/reply scan for ONE account.
|
||||||
Reads current settings flags."""
|
Reads current settings flags."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import sqlite3 as _sql3
|
import sqlite3 as _sql3
|
||||||
import requests as _req
|
from src.llm_core import _uses_max_completion_tokens
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
|
||||||
from src.llm_core import _uses_max_completion_tokens, _restricts_temperature
|
|
||||||
|
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
auto_sum = settings.get("email_auto_summarize", False)
|
auto_sum = settings.get("email_auto_summarize", False)
|
||||||
@@ -265,9 +314,15 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
).fetchall()}
|
).fetchall()}
|
||||||
if auto_tag or auto_spam:
|
if auto_tag or auto_spam:
|
||||||
if account_owner:
|
if account_owner:
|
||||||
_tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags WHERE owner=?", (account_owner,)).fetchall()}
|
_tag_existing = {r[0] for r in _c.execute(
|
||||||
|
"SELECT message_id FROM email_tags WHERE owner=? AND (account_id=? OR account_id='' OR account_id IS NULL)",
|
||||||
|
(account_owner, account_id or ""),
|
||||||
|
).fetchall()}
|
||||||
else:
|
else:
|
||||||
_tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags WHERE owner='' OR owner IS NULL").fetchall()}
|
_tag_existing = {r[0] for r in _c.execute(
|
||||||
|
"SELECT message_id FROM email_tags WHERE (owner='' OR owner IS NULL) AND (account_id=? OR account_id='' OR account_id IS NULL)",
|
||||||
|
(account_id or "",),
|
||||||
|
).fetchall()}
|
||||||
else:
|
else:
|
||||||
_tag_existing = set()
|
_tag_existing = set()
|
||||||
_cal_existing = {r[0] for r in _c.execute(
|
_cal_existing = {r[0] for r in _c.execute(
|
||||||
@@ -296,11 +351,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
if auto_spam and not spam_folder:
|
if auto_spam and not spam_folder:
|
||||||
logger.warning("Auto-spam enabled but no Junk/Spam folder detected — will classify but not move")
|
logger.warning("Auto-spam enabled but no Junk/Spam folder detected — will classify but not move")
|
||||||
|
|
||||||
url, model, headers = resolve_endpoint("utility", owner=account_owner)
|
task_candidates = resolve_task_candidates(owner=account_owner)
|
||||||
if not url:
|
if not task_candidates:
|
||||||
url, model, headers = resolve_endpoint("default", owner=account_owner)
|
|
||||||
if not url or not model:
|
|
||||||
return "No model configured"
|
return "No model configured"
|
||||||
|
url, model, headers = task_candidates[0]
|
||||||
|
|
||||||
writing_style = settings.get("email_writing_style", "")
|
writing_style = settings.get("email_writing_style", "")
|
||||||
processed = 0
|
processed = 0
|
||||||
@@ -314,7 +368,14 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
_reply_failed = 0
|
_reply_failed = 0
|
||||||
_detail_lines = []
|
_detail_lines = []
|
||||||
_current_folder = "INBOX"
|
_current_folder = "INBOX"
|
||||||
_max_process = 5
|
# Calendar extraction is sequential and each row can involve a model
|
||||||
|
# call plus a calendar write. Keep the scheduled calendar-only pass
|
||||||
|
# below the 5-minute action budget instead of timing out mid-run.
|
||||||
|
_default_max_process = 3 if (auto_cal and not auto_sum and not auto_reply and not auto_tag and not auto_spam) else 5
|
||||||
|
try:
|
||||||
|
_max_process = max(1, int(max_process)) if max_process is not None else _default_max_process
|
||||||
|
except Exception:
|
||||||
|
_max_process = _default_max_process
|
||||||
for _entry in uid_list:
|
for _entry in uid_list:
|
||||||
if processed >= _max_process:
|
if processed >= _max_process:
|
||||||
break
|
break
|
||||||
@@ -406,35 +467,17 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
req_headers.update(headers)
|
req_headers.update(headers)
|
||||||
|
|
||||||
if need_sum:
|
if need_sum:
|
||||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
try:
|
||||||
payload = {
|
summary = await task_llm_call_async(
|
||||||
"model": model,
|
messages=[
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull out invoice totals, deadlines, key clauses, any concrete numbers/dates in PDFs/docs, and reflect them in the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning or planning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
|
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull out invoice totals, deadlines, key clauses, any concrete numbers/dates in PDFs/docs, and reflect them in the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning or planning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
|
||||||
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
|
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
|
||||||
],
|
],
|
||||||
tok_key: 16384,
|
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||||
"temperature": 0.3,
|
owner=account_owner or None,
|
||||||
"stream": False,
|
temperature=0.3, max_tokens=16384, timeout=240,
|
||||||
}
|
|
||||||
# Reasoning models (o1/o3/o4/gpt-5) reject an explicit temperature.
|
|
||||||
if _restricts_temperature(model):
|
|
||||||
payload.pop("temperature", None)
|
|
||||||
try:
|
|
||||||
# Use to_thread so this sync HTTP call doesn't freeze
|
|
||||||
# the entire event loop while the LLM thinks (240s).
|
|
||||||
resp = await asyncio.to_thread(
|
|
||||||
_req.post, url, json=payload, headers=req_headers, timeout=240
|
|
||||||
)
|
)
|
||||||
if resp.ok:
|
summary = _extract_reply((summary or "").strip())
|
||||||
rdata = resp.json()
|
|
||||||
m = (rdata.get("choices") or [{}])[0].get("message", {})
|
|
||||||
summary = (m.get("content") or "").strip()
|
|
||||||
summary = _extract_reply(summary)
|
|
||||||
if not summary:
|
|
||||||
rc = (m.get("reasoning_content") or "").strip()
|
|
||||||
bullets = [ln.strip() for ln in rc.split("\n") if re.match(r"^[-•*]\s+|^\d+[.)]\s+", ln.strip())]
|
|
||||||
summary = "\n".join(bullets) if bullets else ""
|
|
||||||
if summary:
|
if summary:
|
||||||
_c = _sql3.connect(SCHEDULED_DB)
|
_c = _sql3.connect(SCHEDULED_DB)
|
||||||
_c.execute("""
|
_c.execute("""
|
||||||
@@ -468,14 +511,14 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
if context_snippets:
|
if context_snippets:
|
||||||
sys_prompt += "\n\nRELEVANT CONTEXT FROM PAST EMAILS AND CONTACTS:\n" + "\n\n---\n\n".join(context_snippets[:5])
|
sys_prompt += "\n\nRELEVANT CONTEXT FROM PAST EMAILS AND CONTACTS:\n" + "\n\n---\n\n".join(context_snippets[:5])
|
||||||
try:
|
try:
|
||||||
reply = await llm_call_async(
|
reply = await task_llm_call_async(
|
||||||
url=url, model=model,
|
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "system", "content": sys_prompt},
|
{"role": "system", "content": sys_prompt},
|
||||||
{"role": "user", "content": f"Original email:\nFrom: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\nDraft a reply. Return only the reply body text."},
|
{"role": "user", "content": f"Original email:\nFrom: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\nDraft a reply. Return only the reply body text."},
|
||||||
],
|
],
|
||||||
temperature=0.7, max_tokens=1024,
|
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||||
headers=req_headers, timeout=90,
|
owner=account_owner or None,
|
||||||
|
temperature=0.7, max_tokens=1024, timeout=90,
|
||||||
)
|
)
|
||||||
reply = _apply_email_style_mechanics(_extract_reply(reply or ""))
|
reply = _apply_email_style_mechanics(_extract_reply(reply or ""))
|
||||||
if reply:
|
if reply:
|
||||||
@@ -502,6 +545,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
# ── Calendar event extraction (independent of reply drafting) ──
|
# ── Calendar event extraction (independent of reply drafting) ──
|
||||||
if need_cal:
|
if need_cal:
|
||||||
_cal_run_count = 0
|
_cal_run_count = 0
|
||||||
|
_cal_event_uids = []
|
||||||
|
_cal_parse_ok = False
|
||||||
try:
|
try:
|
||||||
# Pull a snapshot of upcoming events so the LLM can decide
|
# Pull a snapshot of upcoming events so the LLM can decide
|
||||||
# create vs update vs cancel based on what already exists.
|
# create vs update vs cancel based on what already exists.
|
||||||
@@ -510,8 +555,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
_existing_summary = get_upcoming_events(_acct_owner, horizon_days=60, limit=40)
|
_existing_summary = get_upcoming_events(_acct_owner, horizon_days=60, limit=40)
|
||||||
existing_json = json.dumps(_existing_summary)
|
existing_json = json.dumps(_existing_summary)
|
||||||
is_sent = _folder.lower().startswith("sent") or "sent" in _folder.lower()
|
is_sent = _folder.lower().startswith("sent") or "sent" in _folder.lower()
|
||||||
cal_extract = await llm_call_async(
|
cal_extract = await task_llm_call_async(
|
||||||
url=url, model=model,
|
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"You are a calendar assistant. The user receives emails AND sends replies "
|
"You are a calendar assistant. The user receives emails AND sends replies "
|
||||||
@@ -562,8 +606,9 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
f"{body[:4000]}"
|
f"{body[:4000]}"
|
||||||
)},
|
)},
|
||||||
],
|
],
|
||||||
temperature=0.1, max_tokens=16384,
|
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||||
headers=req_headers, timeout=180,
|
owner=account_owner or None,
|
||||||
|
temperature=0.1, max_tokens=16384, timeout=75,
|
||||||
)
|
)
|
||||||
_raw_original = cal_extract or ""
|
_raw_original = cal_extract or ""
|
||||||
cal_extract = _strip_think(_raw_original)
|
cal_extract = _strip_think(_raw_original)
|
||||||
@@ -573,10 +618,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
if matches:
|
if matches:
|
||||||
cal_extract = matches[-1].group()
|
cal_extract = matches[-1].group()
|
||||||
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
|
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
|
||||||
jm = re.search(r'\[.*\]', cal_extract, re.DOTALL)
|
ops = _extract_json_array_from_text(cal_extract)
|
||||||
if jm:
|
if ops is not None:
|
||||||
try:
|
try:
|
||||||
ops = json.loads(jm.group())
|
_cal_parse_ok = True
|
||||||
logger.info(f"[cal-extract] parsed {len(ops)} op(s)")
|
logger.info(f"[cal-extract] parsed {len(ops)} op(s)")
|
||||||
if isinstance(ops, list) and ops:
|
if isinstance(ops, list) and ops:
|
||||||
from src.tool_implementations import do_manage_calendar
|
from src.tool_implementations import do_manage_calendar
|
||||||
@@ -606,6 +651,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
r = await do_manage_calendar(json.dumps(args), owner=_acct_owner)
|
r = await do_manage_calendar(json.dumps(args), owner=_acct_owner)
|
||||||
if r.get("exit_code", 0) == 0:
|
if r.get("exit_code", 0) == 0:
|
||||||
logger.info(f"[cal-extract] Updated event uid={cuid} → {op.get('title')} {op['date']}")
|
logger.info(f"[cal-extract] Updated event uid={cuid} → {op.get('title')} {op['date']}")
|
||||||
|
if cuid and cuid not in _cal_event_uids:
|
||||||
|
_cal_event_uids.append(cuid)
|
||||||
_cal_run_count += 1
|
_cal_run_count += 1
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[cal-extract] update failed: {r.get('error')}")
|
logger.warning(f"[cal-extract] update failed: {r.get('error')}")
|
||||||
@@ -686,25 +733,37 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
r = await do_manage_calendar(cal_args, owner=_acct_owner)
|
r = await do_manage_calendar(cal_args, owner=_acct_owner)
|
||||||
if r.get("exit_code", 0) == 0:
|
if r.get("exit_code", 0) == 0:
|
||||||
logger.info(f"[cal-extract] Created event: {op['title']} on {op['date']}")
|
logger.info(f"[cal-extract] Created event: {op['title']} on {op['date']}")
|
||||||
|
_created_uid = (r.get("uid") or "").strip()
|
||||||
|
if _created_uid and _created_uid not in _cal_event_uids:
|
||||||
|
_cal_event_uids.append(_created_uid)
|
||||||
_events_created += 1
|
_events_created += 1
|
||||||
_cal_run_count += 1
|
_cal_run_count += 1
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[cal-extract] create failed: {r.get('error')} args={cal_args[:200]}")
|
logger.warning(f"[cal-extract] create failed: {r.get('error')} args={cal_args[:200]}")
|
||||||
except Exception as je:
|
except Exception as je:
|
||||||
logger.warning(f"[cal-extract] JSON parse failed: {je} on raw={cal_extract[:200]!r}")
|
logger.warning(f"[cal-extract] JSON parse failed: {je} on raw={cal_extract[:200]!r}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[cal-extract] no JSON array found on raw={cal_extract[:200]!r}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[cal-extract] Meeting extraction LLM call failed for uid={uid}: {e}")
|
logger.warning(f"[cal-extract] Meeting extraction LLM call failed for uid={uid}: {e}")
|
||||||
else:
|
else:
|
||||||
# Record we processed this email so we don't re-LLM next run.
|
# Record successfully parsed results so we don't re-LLM
|
||||||
# Only mark as processed on success ? transient LLM failures
|
# no-op emails. Transient LLM failures are retried on
|
||||||
# are retried on the next poll run (matches summary/reply pattern).
|
# the next poll run.
|
||||||
try:
|
try:
|
||||||
|
if _cal_parse_ok:
|
||||||
_cc = _sql3.connect(SCHEDULED_DB)
|
_cc = _sql3.connect(SCHEDULED_DB)
|
||||||
_cc.execute(
|
_cc.execute(
|
||||||
"INSERT OR REPLACE INTO email_calendar_extractions "
|
"INSERT OR REPLACE INTO email_calendar_extractions "
|
||||||
"(message_id, owner, uid, events_created, created_at) VALUES (?, ?, ?, ?, ?)",
|
"(message_id, owner, uid, event_uids, events_created, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
(message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
|
(
|
||||||
_cal_run_count, datetime.utcnow().isoformat())
|
message_id,
|
||||||
|
account_owner or "",
|
||||||
|
uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||||
|
json.dumps(_cal_event_uids),
|
||||||
|
_cal_run_count,
|
||||||
|
datetime.utcnow().isoformat(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
_cc.commit()
|
_cc.commit()
|
||||||
_cc.close()
|
_cc.close()
|
||||||
@@ -742,9 +801,11 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
"temperature": 0,
|
"temperature": 0,
|
||||||
tok_key: 200,
|
tok_key: 200,
|
||||||
}
|
}
|
||||||
urg_raw = await llm_call_async(
|
urg_raw = await task_llm_call_async(
|
||||||
url=url, model=model, messages=payload["messages"],
|
messages=payload["messages"],
|
||||||
temperature=0, max_tokens=200, headers=req_headers, timeout=60,
|
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||||
|
owner=account_owner or None,
|
||||||
|
temperature=0, max_tokens=200, timeout=60,
|
||||||
)
|
)
|
||||||
urg_raw = _strip_think(urg_raw or "")
|
urg_raw = _strip_think(urg_raw or "")
|
||||||
urg_raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", urg_raw, flags=re.MULTILINE).strip()
|
urg_raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", urg_raw, flags=re.MULTILINE).strip()
|
||||||
@@ -845,8 +906,13 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
class_sys = (
|
class_sys = (
|
||||||
"Classify the email. Return ONLY a JSON object, no prose, no markdown fences. "
|
"Classify the email. Return ONLY a JSON object, no prose, no markdown fences. "
|
||||||
"Schema: {\"tags\": [\"tag1\"], \"spam\": false, \"reason\": \"short\"}. "
|
"Schema: {\"tags\": [\"tag1\"], \"spam\": false, \"reason\": \"short\"}. "
|
||||||
"Pick 1-2 tags from: work, personal, finance, bills, receipt, travel, "
|
"Pick 1-3 tags from: work, personal, urgent, action-needed, finance, bills, "
|
||||||
"newsletter, promo, notification, security, social, shopping, calendar.\n\n"
|
"receipt, legal, travel, newsletter, promo, notification, security, social, "
|
||||||
|
"shopping, calendar, support.\n\n"
|
||||||
|
"Use work for professional/company/client/operations messages. "
|
||||||
|
"Use personal for friends/family/private-life messages. "
|
||||||
|
"Use urgent for real time-sensitive consequences. "
|
||||||
|
"Use action-needed when the user likely needs to reply, pay, sign, book, or decide.\n\n"
|
||||||
"Set spam=true for ANY of:\n"
|
"Set spam=true for ANY of:\n"
|
||||||
"- Phishing, scams, chain mail, deceptive offers\n"
|
"- Phishing, scams, chain mail, deceptive offers\n"
|
||||||
"- Marketing/promotional blasts (\"special offer\", \"limited time\", discount codes)\n"
|
"- Marketing/promotional blasts (\"special offer\", \"limited time\", discount codes)\n"
|
||||||
@@ -863,31 +929,16 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
"If it's a mass-mailed generic update with no personal CTA, mark spam=true even if from a legitimate service. "
|
"If it's a mass-mailed generic update with no personal CTA, mark spam=true even if from a legitimate service. "
|
||||||
"Reason should be 5-10 words."
|
"Reason should be 5-10 words."
|
||||||
)
|
)
|
||||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
raw_out = await task_llm_call_async(
|
||||||
payload = {
|
messages=[
|
||||||
"model": model,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": class_sys},
|
{"role": "system", "content": class_sys},
|
||||||
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body[:4000]}"},
|
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body[:4000]}"},
|
||||||
],
|
],
|
||||||
tok_key: 512,
|
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||||
"temperature": 0.1,
|
owner=account_owner or None,
|
||||||
"stream": False,
|
temperature=0.1, max_tokens=512, timeout=120,
|
||||||
}
|
|
||||||
# Reasoning models (o1/o3/o4/gpt-5) reject an explicit temperature.
|
|
||||||
if _restricts_temperature(model):
|
|
||||||
payload.pop("temperature", None)
|
|
||||||
# to_thread keeps the event loop responsive during the LLM call
|
|
||||||
resp = await asyncio.to_thread(
|
|
||||||
_req.post, url, json=payload, headers=req_headers, timeout=120
|
|
||||||
)
|
)
|
||||||
if not resp.ok:
|
raw_out = _strip_think((raw_out or "").strip())
|
||||||
logger.warning(f"Auto-classify {uid.decode() if isinstance(uid, bytes) else str(uid)} HTTP {resp.status_code}: {resp.text[:200]}")
|
|
||||||
else:
|
|
||||||
rdata = resp.json()
|
|
||||||
m = (rdata.get("choices") or [{}])[0].get("message", {})
|
|
||||||
raw_out = (m.get("content") or "").strip()
|
|
||||||
raw_out = _strip_think(raw_out)
|
|
||||||
raw_out = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw_out, flags=re.MULTILINE).strip()
|
raw_out = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw_out, flags=re.MULTILINE).strip()
|
||||||
jm = re.search(r'\{.*\}', raw_out, re.DOTALL)
|
jm = re.search(r'\{.*\}', raw_out, re.DOTALL)
|
||||||
parsed = None
|
parsed = None
|
||||||
@@ -897,15 +948,15 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
except Exception:
|
except Exception:
|
||||||
parsed = None
|
parsed = None
|
||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
_ALLOWED_TAGS = {"work","personal","finance","bills","receipt","travel",
|
_ALLOWED_TAGS = {"work","personal","urgent","action-needed","finance","bills",
|
||||||
"newsletter","marketing","notification","security","social",
|
"receipt","legal","travel","newsletter","marketing","notification",
|
||||||
"shopping","calendar"}
|
"security","social","shopping","calendar","support"}
|
||||||
raw_tags = parsed.get("tags") or []
|
raw_tags = parsed.get("tags") or []
|
||||||
if isinstance(raw_tags, str):
|
if isinstance(raw_tags, str):
|
||||||
raw_tags = [raw_tags]
|
raw_tags = [raw_tags]
|
||||||
tags = [t.strip().lower().replace("_", "-") for t in raw_tags if isinstance(t, str)]
|
tags = [t.strip().lower().replace("_", "-") for t in raw_tags if isinstance(t, str)]
|
||||||
tags = ["marketing" if t == "promo" else t for t in tags]
|
tags = ["marketing" if t == "promo" else t for t in tags]
|
||||||
tags = [t for t in tags if t in _ALLOWED_TAGS][:2]
|
tags = [t for t in tags if t in _ALLOWED_TAGS][:3]
|
||||||
is_spam = bool(parsed.get("spam"))
|
is_spam = bool(parsed.get("spam"))
|
||||||
spam_reason = str(parsed.get("reason") or "")[:200]
|
spam_reason = str(parsed.get("reason") or "")[:200]
|
||||||
|
|
||||||
@@ -918,10 +969,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
_c = _sql3.connect(SCHEDULED_DB)
|
_c = _sql3.connect(SCHEDULED_DB)
|
||||||
_c.execute("""
|
_c.execute("""
|
||||||
INSERT OR REPLACE INTO email_tags
|
INSERT OR REPLACE INTO email_tags
|
||||||
(message_id, owner, uid, folder, subject, sender, tags, spam_verdict,
|
(message_id, owner, account_id, uid, folder, subject, sender, tags, spam_verdict,
|
||||||
spam_reason, moved_to, model_used, created_at)
|
spam_reason, moved_to, model_used, created_at)
|
||||||
VALUES (?, ?, ?, 'INBOX', ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid), subject, sender,
|
""", (message_id, account_owner or "", account_id or "", uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender,
|
||||||
json.dumps(tags), 1 if is_spam else 0,
|
json.dumps(tags), 1 if is_spam else 0,
|
||||||
spam_reason, moved_to, model, datetime.utcnow().isoformat()))
|
spam_reason, moved_to, model, datetime.utcnow().isoformat()))
|
||||||
_c.commit()
|
_c.commit()
|
||||||
|
|||||||
+1671
-175
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,7 @@ def _image_to_dict(img: GalleryImage, session_name: str = None) -> Dict[str, Any
|
|||||||
"filename": img.filename,
|
"filename": img.filename,
|
||||||
"url": f"/api/generated-image/{img.filename}",
|
"url": f"/api/generated-image/{img.filename}",
|
||||||
"prompt": img.prompt,
|
"prompt": img.prompt,
|
||||||
|
"caption": img.caption or "",
|
||||||
"model": img.model,
|
"model": img.model,
|
||||||
"size": img.size,
|
"size": img.size,
|
||||||
"quality": img.quality,
|
"quality": img.quality,
|
||||||
|
|||||||
@@ -77,6 +77,39 @@ def _normalize_image_endpoint_base(url: str) -> str:
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _is_openai_api_base(url: str) -> bool:
|
||||||
|
"""Return True only when url's hostname is exactly api.openai.com."""
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
try:
|
||||||
|
candidate = url if "://" in url else f"https://{url}"
|
||||||
|
return urlsplit(candidate).hostname == "api.openai.com"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_GALLERY_ENDPOINT_PATHS = frozenset({
|
||||||
|
"/images/edits",
|
||||||
|
"/images/generations",
|
||||||
|
"/images/harmonize",
|
||||||
|
"/images/img2img",
|
||||||
|
"/images/inpaint",
|
||||||
|
"/images/upscale",
|
||||||
|
"/images/variations",
|
||||||
|
"/sdapi/v1/img2img",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _join_checked_gallery_endpoint(base: str, path: str) -> str:
|
||||||
|
"""Append a known-constant gallery path suffix to a validated base URL.
|
||||||
|
|
||||||
|
Rejects paths not in the pre-approved list so arbitrary strings can never
|
||||||
|
be spliced into the URL passed to httpx.
|
||||||
|
"""
|
||||||
|
if path not in _GALLERY_ENDPOINT_PATHS:
|
||||||
|
raise ValueError(f"Unexpected gallery path: {path!r}")
|
||||||
|
return base + path
|
||||||
|
|
||||||
|
|
||||||
def _visible_image_endpoint_query(db, owner: str | None):
|
def _visible_image_endpoint_query(db, owner: str | None):
|
||||||
from src.auth_helpers import owner_filter
|
from src.auth_helpers import owner_filter
|
||||||
q = db.query(ModelEndpoint).filter(
|
q = db.query(ModelEndpoint).filter(
|
||||||
@@ -255,9 +288,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as e:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(500, f"DB commit failed: {e}")
|
logger.exception("gallery_replace: DB commit failed")
|
||||||
|
raise HTTPException(500, "Image update failed")
|
||||||
return {"ok": True, "width": img.width, "height": img.height}
|
return {"ok": True, "width": img.width, "height": img.height}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -385,8 +419,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
return {"image": data.get("data", [{}])[0].get("b64_json", "")}
|
return {"image": data.get("data", [{}])[0].get("b64_json", "")}
|
||||||
# Fallback: no upscale endpoint — return error
|
# Fallback: no upscale endpoint — return error
|
||||||
return {"error": f"Upscale endpoint not available ({resp.status_code})"}
|
return {"error": f"Upscale endpoint not available ({resp.status_code})"}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return {"error": str(e)}
|
logger.exception("ai_upscale: request failed")
|
||||||
|
return {"error": "Upscale request failed"}
|
||||||
|
|
||||||
# ---- POST /api/gallery/style-transfer ----
|
# ---- POST /api/gallery/style-transfer ----
|
||||||
@router.post("/api/gallery/style-transfer")
|
@router.post("/api/gallery/style-transfer")
|
||||||
@@ -431,8 +466,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
if img_data:
|
if img_data:
|
||||||
return {"image": img_data}
|
return {"image": img_data}
|
||||||
return {"error": f"Style transfer failed ({resp.status_code})"}
|
return {"error": f"Style transfer failed ({resp.status_code})"}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return {"error": str(e)}
|
logger.exception("style_transfer: request failed")
|
||||||
|
return {"error": "Style transfer failed"}
|
||||||
|
|
||||||
# ---- GET /api/gallery/tags ----
|
# ---- GET /api/gallery/tags ----
|
||||||
@router.get("/api/gallery/tags")
|
@router.get("/api/gallery/tags")
|
||||||
@@ -588,9 +624,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
"tags": sorted(all_tags),
|
"tags": sorted(all_tags),
|
||||||
"models": all_models,
|
"models": all_models,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(f"Failed to fetch gallery library: {e}")
|
logger.exception("Failed to fetch gallery library")
|
||||||
raise HTTPException(500, f"Failed to fetch gallery library: {e}")
|
raise HTTPException(500, "Failed to fetch gallery library")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -766,9 +802,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
return _image_to_dict(img)
|
return _image_to_dict(img)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(500, str(e))
|
logger.exception("patch_gallery_image: update failed")
|
||||||
|
raise HTTPException(500, "Image update failed")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -845,9 +882,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
cleared += 1
|
cleared += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "cleared": cleared}
|
return {"ok": True, "cleared": cleared}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(500, str(e))
|
logger.exception("clear_gallery_user_tags: failed")
|
||||||
|
raise HTTPException(500, "Tag update failed")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -871,9 +909,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
cleared += 1
|
cleared += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "cleared": cleared}
|
return {"ok": True, "cleared": cleared}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(500, str(e))
|
logger.exception("clear_gallery_ai_tags: failed")
|
||||||
|
raise HTTPException(500, "Tag update failed")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -909,9 +948,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
img.tags = ', '.join(cleaned)
|
img.tags = ', '.join(cleaned)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "rows_touched": rows_touched, "tags_removed": tags_removed}
|
return {"ok": True, "rows_touched": rows_touched, "tags_removed": tags_removed}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(500, str(e))
|
logger.exception("dedupe_gallery_tags: failed")
|
||||||
|
raise HTTPException(500, "Tag deduplication failed")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -1029,9 +1069,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
return {"status": "deleted", "id": image_id}
|
return {"status": "deleted", "id": image_id}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(500, str(e))
|
logger.exception("delete_gallery_image: failed")
|
||||||
|
raise HTTPException(500, "Image deletion failed")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -1044,21 +1085,22 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
import httpx
|
import httpx
|
||||||
user = require_privilege(request, "can_generate_images")
|
user = require_privilege(request, "can_generate_images")
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
# Use endpoint from request body (editor dropdown) or fall back to DB lookup
|
# Use endpoint from request body (editor dropdown) or fall back to DB lookup.
|
||||||
base = (body.pop("_endpoint", "") or "").rstrip("/")
|
# Store as requested_base to avoid carrying user input into the outbound request.
|
||||||
|
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
||||||
# SSRF hardening: validate a client-supplied endpoint before any
|
# SSRF hardening: validate a client-supplied endpoint before any
|
||||||
# outbound request (mirrors routes/embedding_routes.py).
|
# outbound request (mirrors routes/embedding_routes.py).
|
||||||
if base:
|
if requested_base:
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
ok, reason = check_outbound_url(
|
ok, reason = check_outbound_url(
|
||||||
base,
|
requested_base,
|
||||||
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||||
chosen_model = (body.pop("_model", "") or "").strip()
|
chosen_model = (body.pop("_model", "") or "").strip()
|
||||||
api_key = None
|
api_key = None
|
||||||
if not base:
|
if not requested_base:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = _first_visible_image_endpoint(db, user)
|
ep = _first_visible_image_endpoint(db, user)
|
||||||
@@ -1069,32 +1111,23 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
else:
|
else:
|
||||||
# Pull api_key from the matching DB row so OpenAI auth works.
|
# Resolve the client-supplied base to a registered visible endpoint.
|
||||||
# Users may have stored base_url with/without /v1 suffix and with/without
|
# Admins are not exempted — gallery proxy routes must use a DB row
|
||||||
# trailing slash, so compare normalized forms.
|
# so the outbound URL never depends directly on request-body input.
|
||||||
def _norm_url(u: str) -> str:
|
|
||||||
if not u:
|
|
||||||
return u
|
|
||||||
u = u.rstrip("/")
|
|
||||||
if u.endswith("/v1"):
|
|
||||||
u = u[:-3]
|
|
||||||
return u
|
|
||||||
_target = _norm_url(base)
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = _visible_image_endpoint_for_base(db, _target, user)
|
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
||||||
if ep:
|
if not ep:
|
||||||
base = (ep.base_url or base).rstrip("/")
|
|
||||||
api_key = ep.api_key
|
|
||||||
elif user and not _current_user_is_admin(request, user):
|
|
||||||
raise HTTPException(403, "Choose a registered image endpoint")
|
raise HTTPException(403, "Choose a registered image endpoint")
|
||||||
|
base = ep.base_url.rstrip("/")
|
||||||
|
api_key = ep.api_key
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
if not base.endswith("/v1"):
|
if not base.endswith("/v1"):
|
||||||
base += "/v1"
|
base += "/v1"
|
||||||
|
|
||||||
is_openai = "api.openai.com" in base
|
is_openai = _is_openai_api_base(base)
|
||||||
|
|
||||||
if is_openai:
|
if is_openai:
|
||||||
# OpenAI path: /v1/images/edits with gpt-image-1.
|
# OpenAI path: /v1/images/edits with gpt-image-1.
|
||||||
@@ -1131,8 +1164,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
mask_buf.seek(0)
|
mask_buf.seek(0)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
raise HTTPException(400, f"Failed to prepare OpenAI request: {e}")
|
logger.exception("inpaint_proxy: failed to prepare OpenAI request")
|
||||||
|
raise HTTPException(400, "Failed to prepare inpaint request")
|
||||||
|
|
||||||
width = int(body.get("width") or 1024)
|
width = int(body.get("width") or 1024)
|
||||||
height = int(body.get("height") or 1024)
|
height = int(body.get("height") or 1024)
|
||||||
@@ -1163,9 +1197,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
headers = {"Authorization": f"Bearer {api_key}"}
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
r = await client.post(f"{base}/images/edits", headers=headers, data=data, files=files)
|
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
raise HTTPException(r.status_code, f"OpenAI edit failed: {r.text[:300]}")
|
logger.error("inpaint_proxy OpenAI edit: status %s", r.status_code)
|
||||||
|
raise HTTPException(r.status_code, "OpenAI edit failed")
|
||||||
result = r.json()
|
result = r.json()
|
||||||
raw_b64 = None
|
raw_b64 = None
|
||||||
if result.get("data"):
|
if result.get("data"):
|
||||||
@@ -1212,16 +1247,18 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
if chosen_model:
|
if chosen_model:
|
||||||
body["model"] = chosen_model
|
body["model"] = chosen_model
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
r = await client.post(f"{base}/images/inpaint", json=body)
|
r = await client.post(_join_checked_gallery_endpoint(base, "/images/inpaint"), json=body)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
raise HTTPException(r.status_code, f"Inpaint failed: {r.text[:200]}")
|
logger.error("inpaint_proxy diffusion: status %s", r.status_code)
|
||||||
|
raise HTTPException(r.status_code, "Inpaint request failed")
|
||||||
return r.json()
|
return r.json()
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
raise HTTPException(504, "Inpaint request timed out (120s)")
|
raise HTTPException(504, "Inpaint request timed out (120s)")
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
raise HTTPException(502, f"Inpaint error: {str(e)}")
|
logger.exception("inpaint_proxy: request failed")
|
||||||
|
raise HTTPException(502, "Inpaint request failed")
|
||||||
|
|
||||||
# ---- POST /api/image/harmonize — proper img2img call ----
|
# ---- POST /api/image/harmonize — proper img2img call ----
|
||||||
# Earlier version routed through inpaint with a full-white mask, but
|
# Earlier version routed through inpaint with a full-white mask, but
|
||||||
@@ -1243,24 +1280,23 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
if not image_b64:
|
if not image_b64:
|
||||||
raise HTTPException(400, "No image provided")
|
raise HTTPException(400, "No image provided")
|
||||||
|
|
||||||
endpoint = (body.get("_endpoint") or "").rstrip("/")
|
requested_base = (body.get("_endpoint") or "").rstrip("/")
|
||||||
# SSRF hardening: a client-supplied endpoint is fetched server-side
|
# SSRF hardening: a client-supplied endpoint is fetched server-side
|
||||||
# below, so validate it first (mirrors routes/embedding_routes.py).
|
# below, so validate it first (mirrors routes/embedding_routes.py).
|
||||||
# Local-first means loopback/LAN is allowed by default; the cloud
|
# Local-first means loopback/LAN is allowed by default; the cloud
|
||||||
# metadata range and non-HTTP(S) schemes are always rejected.
|
# metadata range and non-HTTP(S) schemes are always rejected.
|
||||||
if endpoint:
|
if requested_base:
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
ok, reason = check_outbound_url(
|
ok, reason = check_outbound_url(
|
||||||
endpoint,
|
requested_base,
|
||||||
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||||
model = (body.get("_model") or "").strip()
|
model = (body.get("_model") or "").strip()
|
||||||
|
|
||||||
base = endpoint
|
|
||||||
api_key = None
|
api_key = None
|
||||||
if not base:
|
if not requested_base:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = _first_visible_image_endpoint(db, user)
|
ep = _first_visible_image_endpoint(db, user)
|
||||||
@@ -1271,14 +1307,16 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
else:
|
else:
|
||||||
|
# Resolve the client-supplied base to a registered visible endpoint.
|
||||||
|
# Admins are not exempted — gallery proxy routes must use a DB row
|
||||||
|
# so the outbound URL never depends directly on request-body input.
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = _visible_image_endpoint_for_base(db, base, user)
|
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
||||||
if ep:
|
if not ep:
|
||||||
base = (ep.base_url or base).rstrip("/")
|
|
||||||
api_key = ep.api_key
|
|
||||||
elif user and not _current_user_is_admin(request, user):
|
|
||||||
raise HTTPException(403, "Choose a registered image endpoint")
|
raise HTTPException(403, "Choose a registered image endpoint")
|
||||||
|
base = ep.base_url.rstrip("/")
|
||||||
|
api_key = ep.api_key
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -1313,7 +1351,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# source. Earlier hack (alpha-blend the regen back at `strength`)
|
# source. Earlier hack (alpha-blend the regen back at `strength`)
|
||||||
# produced visibly broken results, so we refuse and tell the
|
# produced visibly broken results, so we refuse and tell the
|
||||||
# user to spin up a real diffusion endpoint instead.
|
# user to spin up a real diffusion endpoint instead.
|
||||||
if "api.openai.com" in base:
|
if _is_openai_api_base(base):
|
||||||
raise HTTPException(400,
|
raise HTTPException(400,
|
||||||
"Harmonize needs a diffusion server that supports img2img "
|
"Harmonize needs a diffusion server that supports img2img "
|
||||||
"(SD WebUI / Forge / Comfy). OpenAI's API doesn't expose "
|
"(SD WebUI / Forge / Comfy). OpenAI's API doesn't expose "
|
||||||
@@ -1378,14 +1416,16 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# 1024×1024 inference pass on slower setups.
|
# 1024×1024 inference pass on slower setups.
|
||||||
async with httpx.AsyncClient(timeout=240) as client:
|
async with httpx.AsyncClient(timeout=240) as client:
|
||||||
for path, kind, payload in candidates:
|
for path, kind, payload in candidates:
|
||||||
target = base_root + path if path.startswith("/sdapi") else base + path
|
_effective_base = base_root if path.startswith("/sdapi") else base
|
||||||
|
target = _join_checked_gallery_endpoint(_effective_base, path)
|
||||||
try:
|
try:
|
||||||
r = await client.post(target, json=payload, headers=headers)
|
r = await client.post(target, json=payload, headers=headers)
|
||||||
if r.status_code == 404:
|
if r.status_code == 404:
|
||||||
last_err = f"{path}: 404"
|
last_err = f"{path}: 404"
|
||||||
continue # try next variant
|
continue # try next variant
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
last_err = f"{path}: {r.status_code} {r.text[:120]}"
|
logger.warning("harmonize: %s returned %s", path, r.status_code)
|
||||||
|
last_err = f"{path}: {r.status_code}"
|
||||||
continue
|
continue
|
||||||
data = r.json()
|
data = r.json()
|
||||||
# Normalise return shape.
|
# Normalise return shape.
|
||||||
@@ -1394,8 +1434,8 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# surface it now instead of trying the other routes
|
# surface it now instead of trying the other routes
|
||||||
# (otherwise the real error gets buried under 404s).
|
# (otherwise the real error gets buried under 404s).
|
||||||
if data.get("error") and not data.get("image"):
|
if data.get("error") and not data.get("image"):
|
||||||
raise HTTPException(502,
|
logger.warning("harmonize: server error at %s: %s", path, data.get("error"))
|
||||||
f"Diffusion server error at {path}: {data['error']}")
|
raise HTTPException(502, f"Diffusion server error at {path}")
|
||||||
if data.get("image"):
|
if data.get("image"):
|
||||||
return {"image": data["image"]}
|
return {"image": data["image"]}
|
||||||
if data.get("images") and isinstance(data["images"], list):
|
if data.get("images") and isinstance(data["images"], list):
|
||||||
@@ -1415,15 +1455,15 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
if img_b64:
|
if img_b64:
|
||||||
return {"image": img_b64}
|
return {"image": img_b64}
|
||||||
last_err = f"{path}: server returned no image"
|
last_err = f"{path}: server returned no image"
|
||||||
except httpx.ConnectError as e:
|
except httpx.ConnectError:
|
||||||
raise HTTPException(502, f"Can't reach diffusion server at {base}: {e}")
|
logger.warning("harmonize: can't reach diffusion server at %s", base)
|
||||||
|
raise HTTPException(502, "Can't reach diffusion server")
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
raise HTTPException(504, "Harmonize timed out (240s) — restart the diffusion server or lower Color match / disable Seam fix")
|
raise HTTPException(504, "Harmonize timed out (240s) — restart the diffusion server or lower Color match / disable Seam fix")
|
||||||
raise HTTPException(502,
|
raise HTTPException(502,
|
||||||
f"None of the img2img routes worked on {base}. "
|
"No supported img2img route responded. "
|
||||||
f"Last response: {last_err or 'unknown'}. "
|
"Your diffusion server needs to expose one of: "
|
||||||
"Your diffusion server needs to expose one of /v1/images/harmonize, "
|
"/v1/images/harmonize, /v1/images/img2img, /v1/images/variations, /sdapi/v1/img2img.")
|
||||||
"/v1/images/img2img, /v1/images/variations, or /sdapi/v1/img2img.")
|
|
||||||
|
|
||||||
# ---- POST /api/image/sharpen ----
|
# ---- POST /api/image/sharpen ----
|
||||||
@router.post("/api/image/sharpen")
|
@router.post("/api/image/sharpen")
|
||||||
@@ -1467,8 +1507,8 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
import base64, io
|
import base64, io
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import numpy as np
|
import numpy as np
|
||||||
except ImportError as e:
|
except ImportError:
|
||||||
raise HTTPException(500, f"Server missing dependency: {e}")
|
raise HTTPException(500, "Server missing a required dependency")
|
||||||
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
|
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
|
||||||
img_bytes = base64.b64decode(image_b64)
|
img_bytes = base64.b64decode(image_b64)
|
||||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||||
@@ -1495,9 +1535,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
out_img.save(buf, format="PNG")
|
out_img.save(buf, format="PNG")
|
||||||
return {"image": base64.b64encode(buf.getvalue()).decode()}
|
return {"image": base64.b64encode(buf.getvalue()).decode()}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.warning(f"Denoise failed: {e}")
|
logger.warning("Denoise failed", exc_info=True)
|
||||||
return {"error": f"Denoise failed: {e}"}
|
return {"error": "Denoise failed"}
|
||||||
|
|
||||||
# ---- POST /api/image/upscale-local ----
|
# ---- POST /api/image/upscale-local ----
|
||||||
# Local Real-ESRGAN upscale (2× or 4×). Self-contained — no diffusion
|
# Local Real-ESRGAN upscale (2× or 4×). Self-contained — no diffusion
|
||||||
@@ -1518,8 +1558,8 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
import base64, io
|
import base64, io
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import numpy as np
|
import numpy as np
|
||||||
except ImportError as e:
|
except ImportError:
|
||||||
raise HTTPException(500, f"Server missing dependency: {e}")
|
raise HTTPException(500, "Server missing a required dependency")
|
||||||
img_bytes = base64.b64decode(image_b64)
|
img_bytes = base64.b64decode(image_b64)
|
||||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||||
try:
|
try:
|
||||||
@@ -1543,9 +1583,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
out_img.save(buf, format="PNG")
|
out_img.save(buf, format="PNG")
|
||||||
return {"image": base64.b64encode(buf.getvalue()).decode()}
|
return {"image": base64.b64encode(buf.getvalue()).decode()}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.warning(f"Upscale failed: {e}")
|
logger.warning("AI upscale failed", exc_info=True)
|
||||||
return {"error": f"Upscale failed: {e}"}
|
return {"error": "AI upscale failed"}
|
||||||
|
|
||||||
# ---- POST /api/image/remove-bg ----
|
# ---- POST /api/image/remove-bg ----
|
||||||
@router.post("/api/image/remove-bg")
|
@router.post("/api/image/remove-bg")
|
||||||
@@ -1703,8 +1743,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
enhanced.save(buf, format="PNG")
|
enhanced.save(buf, format="PNG")
|
||||||
return {"image": base64.b64encode(buf.getvalue()).decode(), "method": "pil"}
|
return {"image": base64.b64encode(buf.getvalue()).decode(), "method": "pil"}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
raise HTTPException(500, f"Face enhancement failed: {str(e)}")
|
logger.exception("enhance_face: failed")
|
||||||
|
raise HTTPException(500, "Face enhancement failed")
|
||||||
|
|
||||||
# ---- Album management (path-param routes) ----
|
# ---- Album management (path-param routes) ----
|
||||||
|
|
||||||
@@ -1899,9 +1940,8 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
async with httpx.AsyncClient(timeout=60) as client:
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
resp = await client.post(chat_url, json=payload, headers=h)
|
resp = await client.post(chat_url, json=payload, headers=h)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
body = resp.text[:500]
|
logger.error("ai_tag vision model: status %s: %s", resp.status_code, resp.text[:500])
|
||||||
logger.error(f"Vision model {resp.status_code}: {body}")
|
return {"error": "Vision model request failed"}
|
||||||
return {"error": f"Vision model returned {resp.status_code}: {body[:200]}"}
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
# Anthropic returns content[0].text, OpenAI returns choices[0].message.content
|
# Anthropic returns content[0].text, OpenAI returns choices[0].message.content
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
@@ -1917,9 +1957,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
return {"ok": True, "ai_tags": tag_str}
|
return {"ok": True, "ai_tags": tag_str}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(f"AI tagging failed: {e}")
|
logger.exception("AI tagging failed")
|
||||||
return {"error": str(e)}
|
return {"error": "Auto-tagging failed"}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/gallery/gallery_helpers.py.
|
"""Backward-compat shim - canonical location is routes/gallery/gallery_helpers.py.
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
that ``import routes.gallery_helpers``, ``from routes.gallery_helpers import X``,
|
that ``import routes.gallery_helpers``, ``from routes.gallery_helpers import X``,
|
||||||
``importlib.import_module("routes.gallery_helpers")``, and
|
``importlib.import_module("routes.gallery_helpers")``, and
|
||||||
``monkeypatch.setattr(routes.gallery_helpers, ...)`` all operate on the *same*
|
``monkeypatch.setattr(routes.gallery_helpers, ...)`` all operate on the same
|
||||||
object. Keeps existing import paths working after slice 2a (#4082/#4071).
|
object. Keeps existing import paths working after slice 2a (#4082/#4071).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/gallery/gallery_routes.py.
|
"""Backward-compat shim - canonical location is routes/gallery/gallery_routes.py.
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
that ``import routes.gallery_routes``, ``from routes.gallery_routes import X``,
|
that ``import routes.gallery_routes``, ``from routes.gallery_routes import X``,
|
||||||
``importlib.import_module("routes.gallery_routes")``, and
|
``importlib.import_module("routes.gallery_routes")``, and
|
||||||
``monkeypatch.setattr(routes.gallery_routes, ...)`` all operate on the *same*
|
``monkeypatch.setattr(routes.gallery_routes, ...)`` all operate on the same
|
||||||
object the application actually uses. Keeps existing import paths working
|
object the application actually uses. Keeps existing import paths working
|
||||||
after slice 2a (#4082/#4071). Source-introspection tests read the canonical
|
after slice 2a (#4082/#4071). Source-introspection tests read the canonical
|
||||||
file by path.
|
file by path.
|
||||||
|
|||||||
@@ -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
|
||||||
+12
-657
@@ -1,662 +1,17 @@
|
|||||||
"""History routes — session history, truncation, fork, conversation topics."""
|
"""Backward-compat shim — canonical location is routes/history/history_routes.py.
|
||||||
|
|
||||||
import json
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
import uuid
|
that ``import routes.history_routes``, ``from routes.history_routes import X``,
|
||||||
import logging
|
``importlib.import_module("routes.history_routes")``, and the
|
||||||
from typing import Dict, Any
|
``import ... as history_routes`` + ``monkeypatch.setattr(history_routes, ...)``
|
||||||
|
pattern used by test_history_compact_tool_calls.py / test_fork_session_metadata.py
|
||||||
from fastapi import APIRouter, Request, HTTPException
|
all operate on the *same* object the application actually uses. Keeps existing
|
||||||
|
import paths working after slice 2d (#4082/#4071). Source-introspection tests
|
||||||
from core.models import ChatMessage
|
read the canonical file by path.
|
||||||
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__)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
def setup_history_routes(session_manager) -> APIRouter:
|
from routes.history import history_routes as _canonical # noqa: F401
|
||||||
router = APIRouter(tags=["history"])
|
|
||||||
|
|
||||||
@router.get("/api/history/{session_id}")
|
_sys.modules[__name__] = _canonical
|
||||||
async def get_session_history(request: Request, session_id: str) -> Dict[str, Any]:
|
|
||||||
_verify_session_owner(request, session_id)
|
|
||||||
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": 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": 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()
|
|
||||||
)
|
|
||||||
import json as _json
|
|
||||||
db_history = []
|
|
||||||
for m in db_messages:
|
|
||||||
entry = {"role": m.role, "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
|
|
||||||
db_history.append(entry)
|
|
||||||
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
|
|
||||||
|
|||||||
+12
-3
@@ -191,7 +191,7 @@ def setup_hwfit_routes():
|
|||||||
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
|
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
|
||||||
|
|
||||||
@router.get("/models")
|
@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.
|
"""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
|
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
|
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."""
|
fresh=true bypasses the hardware-detection cache."""
|
||||||
from services.hwfit.hardware import detect_system
|
from services.hwfit.hardware import detect_system
|
||||||
from services.hwfit.fit import rank_models
|
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)
|
host, ssh_port = _validate_detection_target(host, ssh_port)
|
||||||
system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh))
|
system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh))
|
||||||
if system.get("error"):
|
if system.get("error"):
|
||||||
return {"system": system, "models": [], "error": system["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():
|
if not get_models():
|
||||||
return {
|
return {
|
||||||
"system": system,
|
"system": system,
|
||||||
@@ -304,7 +310,10 @@ def setup_hwfit_routes():
|
|||||||
rank_kwargs.pop("target_context", None)
|
rank_kwargs.pop("target_context", None)
|
||||||
rank_kwargs.pop("fit_only", None)
|
rank_kwargs.pop("fit_only", None)
|
||||||
results = rank_models(system, **rank_kwargs)
|
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")
|
@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 = ""):
|
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 = ""):
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Memory route domain package (slice 2c, #4082/#4071).
|
||||||
|
|
||||||
|
Contains memory_routes.py, migrated from the flat routes/ directory.
|
||||||
|
Backward-compat shim at routes/memory_routes.py re-exports from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
# routes/memory_routes.py
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Request, UploadFile, File
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Leading list-marker like "1.", "12)", or "3:" plus surrounding whitespace.
|
||||||
|
# Strips one prefix per call so import-from-LLM-output doesn't leave the
|
||||||
|
# numbering inside the saved memory text. Bullet markers (-, *, •) are
|
||||||
|
# also peeled here for the same reason.
|
||||||
|
_LIST_PREFIX_RE = re.compile(r"^\s*(?:\d{1,3}[.):]\s+|[-*•]\s+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_list_prefix(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
return _LIST_PREFIX_RE.sub("", text, count=1).strip()
|
||||||
|
|
||||||
|
from services.memory import MemoryManager
|
||||||
|
from core.session_manager import SessionManager
|
||||||
|
from src.request_models import MemoryAddRequest
|
||||||
|
from core.database import SessionLocal
|
||||||
|
from src.llm_core import llm_call_async
|
||||||
|
from services.memory.memory_extractor import audit_memories
|
||||||
|
from src.auth_helpers import get_current_user, require_user
|
||||||
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
from src.task_endpoint import resolve_task_endpoint
|
||||||
|
from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
|
||||||
|
"""Set up memory-related routes."""
|
||||||
|
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
||||||
|
|
||||||
|
def _owner(request: Request) -> Optional[str]:
|
||||||
|
return get_current_user(request)
|
||||||
|
|
||||||
|
def _assert_session_owner(session_obj, user):
|
||||||
|
"""SECURITY: 404 if the caller does not own this session.
|
||||||
|
|
||||||
|
SessionManager.get_session is NOT owner-scoped — it returns any
|
||||||
|
session by id. These routes accept a caller-supplied session id, so
|
||||||
|
without this gate a user could target another tenant's session and
|
||||||
|
leak their chat history, their session-scoped LLM credentials, or the
|
||||||
|
session title. Mirrors session_routes / webhook_routes ownership.
|
||||||
|
"""
|
||||||
|
if user is not None and getattr(session_obj, "owner", None) != user:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
|
||||||
|
def _verify_memory_owner(memory: dict, user: Optional[str]):
|
||||||
|
"""Raise 404 if user doesn't own this memory.
|
||||||
|
|
||||||
|
SECURITY: strict ownership — previously `mem_owner and mem_owner != user`
|
||||||
|
allowed any user to read/edit/delete memories with an empty/null owner
|
||||||
|
field, which leaked legacy data across the multi-user deploy.
|
||||||
|
"""
|
||||||
|
if user is None:
|
||||||
|
return # Auth disabled
|
||||||
|
if memory.get("owner") != user:
|
||||||
|
raise HTTPException(404, "Memory not found")
|
||||||
|
|
||||||
|
@router.post("/debug")
|
||||||
|
def debug_memory_relevance(request: Request, query: str = Form(...)):
|
||||||
|
"""Debug which memories would be triggered for a query"""
|
||||||
|
user = _owner(request)
|
||||||
|
memories = memory_manager.load(owner=user)
|
||||||
|
relevant = memory_manager.get_relevant_memories(query, memories, threshold=0.05)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": query,
|
||||||
|
"total_memories": len(memories),
|
||||||
|
"relevant_count": len(relevant),
|
||||||
|
"relevant_memories": [{"text": m["text"], "category": m.get("category", "unknown")}
|
||||||
|
for m in relevant]
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/add", response_model=Dict[str, Any])
|
||||||
|
async def api_add_memory(
|
||||||
|
request: Request,
|
||||||
|
memory_data: Optional[MemoryAddRequest] = None
|
||||||
|
):
|
||||||
|
"""Add a new memory entry with optional category, source, and session reference."""
|
||||||
|
from src.auth_helpers import require_privilege
|
||||||
|
require_privilege(request, "can_manage_memory")
|
||||||
|
if memory_data is None:
|
||||||
|
form = await request.form()
|
||||||
|
memory_data = MemoryAddRequest(
|
||||||
|
text=form.get("text"),
|
||||||
|
category=form.get("category", "fact"),
|
||||||
|
source=form.get("source", "user"),
|
||||||
|
session_id=form.get("session_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
user = _owner(request)
|
||||||
|
text = (memory_data.text or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(400, "empty memory")
|
||||||
|
user_mem = memory_manager.load(owner=user)
|
||||||
|
if memory_manager.find_duplicates(text, user_mem):
|
||||||
|
return {"ok": True, "count": len(user_mem), "message": "Memory already exists"}
|
||||||
|
|
||||||
|
if memory_data.session_id:
|
||||||
|
try:
|
||||||
|
session_obj = session_manager.get_session(memory_data.session_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
_assert_session_owner(session_obj, user)
|
||||||
|
|
||||||
|
new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user)
|
||||||
|
if memory_data.session_id:
|
||||||
|
new_entry["session_id"] = memory_data.session_id
|
||||||
|
all_mem = memory_manager.load_all()
|
||||||
|
all_mem.append(new_entry)
|
||||||
|
memory_manager.save(all_mem)
|
||||||
|
# Sync vector index
|
||||||
|
if memory_vector and memory_vector.healthy:
|
||||||
|
memory_vector.add(new_entry["id"], text)
|
||||||
|
try:
|
||||||
|
from src.event_bus import fire_event
|
||||||
|
fire_event("memory_added", user)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("memory_added event dispatch failed", exc_info=True)
|
||||||
|
return {"ok": True, "count": len([m for m in all_mem if m.get("owner") == user])}
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def api_get_memory(request: Request):
|
||||||
|
"""Return all memory entries with their metadata."""
|
||||||
|
user = _owner(request)
|
||||||
|
return {"memory": memory_manager.load(owner=user)}
|
||||||
|
|
||||||
|
@router.post("/search")
|
||||||
|
def search_memories(request: Request, query: str = Form(...), session_id: str = Form(None), category: str = Form(None)):
|
||||||
|
"""Search across all memories with optional filters."""
|
||||||
|
user = _owner(request)
|
||||||
|
memories = memory_manager.load(owner=user)
|
||||||
|
|
||||||
|
if session_id:
|
||||||
|
memories = [m for m in memories if m.get("session_id") == session_id]
|
||||||
|
|
||||||
|
if category:
|
||||||
|
memories = [m for m in memories if category in m.get("categories", [m.get("category", "")])]
|
||||||
|
|
||||||
|
relevant = memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
|
||||||
|
|
||||||
|
return {"memories": relevant, "total": len(relevant), "query": query}
|
||||||
|
|
||||||
|
@router.get("/timeline")
|
||||||
|
def memory_timeline(request: Request):
|
||||||
|
"""Get memories in chronological order with source session information."""
|
||||||
|
user = _owner(request)
|
||||||
|
memories = memory_manager.load(owner=user)
|
||||||
|
sorted_memories = sorted(memories, key=lambda x: x.get("timestamp", 0), reverse=True)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for memory in sorted_memories:
|
||||||
|
if "timestamp" in memory:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromtimestamp(memory["timestamp"])
|
||||||
|
memory["timestamp_str"] = dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except (ValueError, OSError, OverflowError):
|
||||||
|
memory["timestamp_str"] = "Unknown"
|
||||||
|
else:
|
||||||
|
memory["timestamp_str"] = "Unknown"
|
||||||
|
|
||||||
|
session_id = memory.get("session_id")
|
||||||
|
if session_id and session_id in session_manager.sessions:
|
||||||
|
try:
|
||||||
|
session = session_manager.get_session(session_id)
|
||||||
|
if session:
|
||||||
|
_assert_session_owner(session, user)
|
||||||
|
memory["session_name"] = session.name if session else f"Session {session_id[:6]}"
|
||||||
|
except KeyError:
|
||||||
|
memory["session_name"] = "Unknown"
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code != 404:
|
||||||
|
raise
|
||||||
|
memory["session_name"] = "Unknown"
|
||||||
|
else:
|
||||||
|
memory["session_name"] = "Unknown"
|
||||||
|
|
||||||
|
results.append(memory)
|
||||||
|
|
||||||
|
return {"timeline": results, "total": len(results)}
|
||||||
|
|
||||||
|
@router.get("/by-session/{session_id}")
|
||||||
|
def get_memory_by_session(request: Request, session_id: str):
|
||||||
|
"""Get all memories associated with a specific session."""
|
||||||
|
user = _owner(request)
|
||||||
|
try:
|
||||||
|
_session_obj = session_manager.get_session(session_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, f"Session {session_id} not found")
|
||||||
|
_assert_session_owner(_session_obj, user)
|
||||||
|
memories = memory_manager.load(owner=user)
|
||||||
|
session_memories = [m for m in memories if m.get("session_id") == session_id]
|
||||||
|
|
||||||
|
session_memories.sort(key=lambda x: x.get("timestamp", 0), reverse=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = session_manager.get_session(session_id)
|
||||||
|
session_name = session.name if session else f"Session {session_id[:6]}"
|
||||||
|
except KeyError:
|
||||||
|
session_name = f"Session {session_id[:6]}"
|
||||||
|
|
||||||
|
for memory in session_memories:
|
||||||
|
memory["session_name"] = session_name
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"session_name": session_name,
|
||||||
|
"memory_count": len(session_memories),
|
||||||
|
"memories": session_memories
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/extract")
|
||||||
|
async def extract_memory(request: Request, session: str = Form(...)) -> Dict[str, List[str]]:
|
||||||
|
"""Analyze a session's chat history and return memory suggestions."""
|
||||||
|
require_user(request)
|
||||||
|
try:
|
||||||
|
sess = session_manager.get_session(session)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
_assert_session_owner(sess, _owner(request))
|
||||||
|
|
||||||
|
system_msg = {
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"You are a helpful assistant. Analyze the entire conversation history provided and extract any "
|
||||||
|
"useful factual statements, contacts, addresses, phone numbers, or other information that the user "
|
||||||
|
"might want to remember for future interactions. Return each piece of information as a JSON object "
|
||||||
|
"with a 'text' field. For example: [{'text': 'Alice lives at 123 Main St'}, {'text': 'Bob works at Acme Corp'}]. "
|
||||||
|
"Only include information that is specific and likely to be useful later."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
messages = [system_msg] + sess.get_context_messages()
|
||||||
|
|
||||||
|
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||||
|
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
suggestion_text = await llm_call_async(
|
||||||
|
t_url,
|
||||||
|
t_model,
|
||||||
|
messages,
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=500,
|
||||||
|
headers=t_headers,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
suggestions = json.loads(suggestion_text)
|
||||||
|
if isinstance(suggestions, list):
|
||||||
|
suggestions = [s if isinstance(s, str) else s.get("text", "") for s in suggestions]
|
||||||
|
else:
|
||||||
|
suggestions = []
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
suggestions = [line.strip() for line in suggestion_text.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
return {"suggestions": [s for s in suggestions if s]}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LLM memory extraction failed (session {session}): {e}")
|
||||||
|
fallback = memory_manager.extract_memory_from_chat(sess.history, session)
|
||||||
|
return {"suggestions": [item["text"] for item in fallback]}
|
||||||
|
|
||||||
|
@router.post("/audit")
|
||||||
|
async def api_audit_memories(request: Request, session: str = Form(None)):
|
||||||
|
"""Deduplicate and consolidate memories via LLM.
|
||||||
|
|
||||||
|
Uses task/utility/default settings through the shared resolver, with
|
||||||
|
the active session as fallback when no task or utility model is set.
|
||||||
|
Returns before and after memory counts.
|
||||||
|
"""
|
||||||
|
user = _owner(request)
|
||||||
|
fallback_url = fallback_model = None
|
||||||
|
fallback_headers = None
|
||||||
|
if session:
|
||||||
|
try:
|
||||||
|
sess = session_manager.get_session(session)
|
||||||
|
_assert_session_owner(sess, user)
|
||||||
|
fallback_url = sess.endpoint_url
|
||||||
|
fallback_model = sess.model
|
||||||
|
fallback_headers = sess.headers
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
endpoint_url, model, headers = resolve_task_endpoint(
|
||||||
|
fallback_url, fallback_model, fallback_headers, owner=user
|
||||||
|
)
|
||||||
|
|
||||||
|
if not endpoint_url or not model:
|
||||||
|
raise HTTPException(400, "No default model configured — set one in Settings")
|
||||||
|
|
||||||
|
result = await audit_memories(
|
||||||
|
memory_manager,
|
||||||
|
memory_vector,
|
||||||
|
endpoint_url,
|
||||||
|
model,
|
||||||
|
headers,
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in result and "before" not in result:
|
||||||
|
raise HTTPException(502, f"Audit failed: {result['error']}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": "error" not in result,
|
||||||
|
"before": result.get("before", 0),
|
||||||
|
"after": result.get("after", 0),
|
||||||
|
"removed": result.get("before", 0) - result.get("after", 0),
|
||||||
|
# True when the audit skipped the LLM because nothing changed
|
||||||
|
# since the last tidy. Frontend already says "Already clean"
|
||||||
|
# for removed==0, so this is here for future use / debugging.
|
||||||
|
"already_tidy": bool(result.get("already_tidy")),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/import")
|
||||||
|
async def import_memories_from_file(
|
||||||
|
request: Request,
|
||||||
|
session: str | None = Form(None),
|
||||||
|
file: UploadFile = File(...)
|
||||||
|
):
|
||||||
|
"""Extract memory suggestions from an uploaded file (PDF, TXT, MD, etc.)."""
|
||||||
|
from src.auth_helpers import require_privilege
|
||||||
|
require_privilege(request, "can_manage_memory")
|
||||||
|
|
||||||
|
endpoint_url = None
|
||||||
|
model = None
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
user = _owner(request)
|
||||||
|
|
||||||
|
if session:
|
||||||
|
try:
|
||||||
|
sess = session_manager.get_session(session)
|
||||||
|
_assert_session_owner(sess, user)
|
||||||
|
except KeyError:
|
||||||
|
sess = None
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code != 404:
|
||||||
|
raise
|
||||||
|
sess = None
|
||||||
|
|
||||||
|
if sess is None:
|
||||||
|
logger.warning("Session %s not found or inaccessible, falling back to utility endpoint", session)
|
||||||
|
endpoint_url, model, headers = resolve_endpoint("utility", owner=user)
|
||||||
|
else:
|
||||||
|
endpoint_url, model, headers = resolve_task_endpoint(
|
||||||
|
sess.endpoint_url, sess.model, sess.headers, owner=user
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
endpoint_url, model, headers = resolve_task_endpoint(owner=user)
|
||||||
|
|
||||||
|
if not endpoint_url or not model:
|
||||||
|
raise HTTPException(400, "No LLM model configured. Set a default model in Settings.")
|
||||||
|
|
||||||
|
content = await read_upload_limited(file, MEMORY_IMPORT_MAX_BYTES, "Memory import")
|
||||||
|
filename = file.filename or "upload"
|
||||||
|
_, ext = os.path.splitext(filename.lower())
|
||||||
|
|
||||||
|
allowed = {".txt", ".md", ".pdf", ".csv", ".log", ".json", ".py", ".js", ".html"}
|
||||||
|
if ext not in allowed:
|
||||||
|
raise HTTPException(400, f"Unsupported file type: {ext}")
|
||||||
|
|
||||||
|
# Extract text based on file type
|
||||||
|
if ext == ".pdf":
|
||||||
|
from src.document_processor import _process_pdf
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
||||||
|
tmp.write(content)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
try:
|
||||||
|
text = _process_pdf(tmp_path, owner=_owner(request))
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
from charset_normalizer import detect
|
||||||
|
encoding = (detect(content) or {}).get("encoding") or "utf-8"
|
||||||
|
text = content.decode(encoding, errors="replace")
|
||||||
|
|
||||||
|
if not text.strip():
|
||||||
|
return {"suggestions": [], "message": "No readable content found"}
|
||||||
|
|
||||||
|
# Fast path: a .json upload that already looks like a memories export
|
||||||
|
# (list of {text, category, ...} dicts, or list of strings) round-trips
|
||||||
|
# directly without spending an LLM call to re-extract its own output.
|
||||||
|
# Without this, re-importing a memories.json from another account
|
||||||
|
# ran the file through the extractor, which often re-emitted the
|
||||||
|
# entries as a numbered list (and the numbering leaked into the
|
||||||
|
# `text` field).
|
||||||
|
if ext == ".json":
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = None
|
||||||
|
if isinstance(parsed, list) and parsed:
|
||||||
|
direct = []
|
||||||
|
for item in parsed:
|
||||||
|
if isinstance(item, dict) and item.get("text"):
|
||||||
|
direct.append({
|
||||||
|
"text": _strip_list_prefix(str(item["text"])),
|
||||||
|
"category": item.get("category") or "fact",
|
||||||
|
})
|
||||||
|
elif isinstance(item, str) and item.strip():
|
||||||
|
direct.append({
|
||||||
|
"text": _strip_list_prefix(item.strip()),
|
||||||
|
"category": "fact",
|
||||||
|
})
|
||||||
|
if direct:
|
||||||
|
return {"suggestions": direct, "filename": filename}
|
||||||
|
|
||||||
|
# Truncate very long documents
|
||||||
|
if len(text) > 15000:
|
||||||
|
text = text[:15000] + "\n[Truncated]"
|
||||||
|
|
||||||
|
# Send to LLM for memory extraction
|
||||||
|
import_prompt = (
|
||||||
|
"You are a memory extraction assistant. The user uploaded a document. "
|
||||||
|
"Analyze the text below and extract specific, useful facts — things like "
|
||||||
|
"names, preferences, jobs, locations, relationships, opinions, projects, "
|
||||||
|
"goals, contacts, or any other personal details worth remembering.\n\n"
|
||||||
|
"Rules:\n"
|
||||||
|
"- Each fact should be a short, self-contained statement\n"
|
||||||
|
"- Do NOT extract generic knowledge\n"
|
||||||
|
"- Focus on personal, memorable information\n"
|
||||||
|
"- If there are no useful facts, return an empty array\n\n"
|
||||||
|
"Return a JSON array of objects with 'text' and 'category' fields.\n"
|
||||||
|
"Categories: 'identity', 'preference', 'fact', 'contact', 'project', 'goal'\n\n"
|
||||||
|
"Return ONLY valid JSON, no markdown fences."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await llm_call_async(
|
||||||
|
endpoint_url,
|
||||||
|
model,
|
||||||
|
[
|
||||||
|
{"role": "system", "content": import_prompt},
|
||||||
|
{"role": "user", "content": f"Document: {filename}\n\n{text}"},
|
||||||
|
],
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=2000,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse JSON
|
||||||
|
raw = raw.strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||||
|
|
||||||
|
suggestions = json.loads(raw)
|
||||||
|
if isinstance(suggestions, list):
|
||||||
|
normalized = []
|
||||||
|
for s in suggestions:
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
if isinstance(s, dict):
|
||||||
|
s = dict(s)
|
||||||
|
if s.get("text"):
|
||||||
|
s["text"] = _strip_list_prefix(str(s["text"]))
|
||||||
|
normalized.append(s)
|
||||||
|
else:
|
||||||
|
normalized.append({"text": _strip_list_prefix(str(s)), "category": "fact"})
|
||||||
|
suggestions = normalized
|
||||||
|
else:
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
return {"suggestions": suggestions, "filename": filename}
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Fallback: split by lines, stripping any "1.", "2)" markdown-list
|
||||||
|
# numbering the model added so saved memories don't keep the prefix.
|
||||||
|
lines = [_strip_list_prefix(l.strip()) for l in raw.splitlines() if l.strip() and len(l.strip()) > 5]
|
||||||
|
return {"suggestions": [{"text": l, "category": "fact"} for l in lines[:20]], "filename": filename}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Memory import extraction failed: {e}")
|
||||||
|
raise HTTPException(502, f"LLM extraction failed: {str(e)}")
|
||||||
|
|
||||||
|
@router.post("/{memory_id}/pin")
|
||||||
|
def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)):
|
||||||
|
"""Pin or unpin a memory. Pinned memories are always included in context."""
|
||||||
|
user = _owner(request)
|
||||||
|
all_mem = memory_manager.load_all()
|
||||||
|
for i, memory in enumerate(all_mem):
|
||||||
|
if memory["id"] == memory_id:
|
||||||
|
_verify_memory_owner(memory, user)
|
||||||
|
all_mem[i]["pinned"] = pinned
|
||||||
|
memory_manager.save(all_mem)
|
||||||
|
return {"ok": True, "pinned": pinned}
|
||||||
|
raise HTTPException(404, f"Memory item {memory_id} not found")
|
||||||
|
|
||||||
|
# Wildcard routes MUST come last — otherwise they swallow /import, /search, etc.
|
||||||
|
@router.get("/{memory_id}")
|
||||||
|
def get_memory_item(request: Request, memory_id: str):
|
||||||
|
"""Get a specific memory item by ID."""
|
||||||
|
user = _owner(request)
|
||||||
|
memories = memory_manager.load(owner=user)
|
||||||
|
for memory in memories:
|
||||||
|
if memory["id"] == memory_id:
|
||||||
|
return {"memory": memory}
|
||||||
|
|
||||||
|
raise HTTPException(404, "Memory not found")
|
||||||
|
|
||||||
|
@router.put("/{memory_id}")
|
||||||
|
def update_memory(request: Request, memory_id: str, text: str = Form(...), category: str = Form(None)):
|
||||||
|
"""Update an existing memory item with new text and optional category."""
|
||||||
|
user = _owner(request)
|
||||||
|
all_mem = memory_manager.load_all()
|
||||||
|
for i, memory in enumerate(all_mem):
|
||||||
|
if memory["id"] == memory_id:
|
||||||
|
_verify_memory_owner(memory, user)
|
||||||
|
all_mem[i]["text"] = text.strip()
|
||||||
|
if category:
|
||||||
|
all_mem[i]["category"] = category
|
||||||
|
all_mem[i]["timestamp"] = int(time.time())
|
||||||
|
|
||||||
|
memory_manager.save(all_mem)
|
||||||
|
# Sync vector index (remove old, add updated)
|
||||||
|
if memory_vector and memory_vector.healthy:
|
||||||
|
memory_vector.remove(memory_id)
|
||||||
|
memory_vector.add(memory_id, text.strip())
|
||||||
|
return {"ok": True, "message": "Memory updated successfully"}
|
||||||
|
|
||||||
|
raise HTTPException(404, f"Memory item {memory_id} not found")
|
||||||
|
|
||||||
|
@router.delete("/{memory_id}")
|
||||||
|
def delete_memory(request: Request, memory_id: str):
|
||||||
|
"""Delete a memory item by its ID."""
|
||||||
|
user = _owner(request)
|
||||||
|
all_mem = memory_manager.load_all()
|
||||||
|
|
||||||
|
# Find and verify ownership before deleting
|
||||||
|
target = next((m for m in all_mem if m["id"] == memory_id), None)
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, f"Memory item {memory_id} not found")
|
||||||
|
_verify_memory_owner(target, user)
|
||||||
|
|
||||||
|
all_mem = [m for m in all_mem if m["id"] != memory_id]
|
||||||
|
memory_manager.save(all_mem)
|
||||||
|
# Sync vector index
|
||||||
|
if memory_vector and memory_vector.healthy:
|
||||||
|
memory_vector.remove(memory_id)
|
||||||
|
return {"ok": True, "message": "Memory deleted successfully"}
|
||||||
|
|
||||||
|
return router
|
||||||
+13
-547
@@ -1,552 +1,18 @@
|
|||||||
# routes/memory_routes.py
|
"""Backward-compat shim — canonical location is routes/memory/memory_routes.py.
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request, UploadFile, File
|
|
||||||
from typing import Dict, Any, Optional, List
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
import logging
|
|
||||||
|
|
||||||
# Leading list-marker like "1.", "12)", or "3:" plus surrounding whitespace.
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
# Strips one prefix per call so import-from-LLM-output doesn't leave the
|
that ``import routes.memory_routes``, ``from routes.memory_routes import X``,
|
||||||
# numbering inside the saved memory text. Bullet markers (-, *, •) are
|
``importlib.import_module("routes.memory_routes")``, and
|
||||||
# also peeled here for the same reason.
|
``monkeypatch.setattr(routes.memory_routes, "ATTR", ...)`` (used by
|
||||||
_LIST_PREFIX_RE = re.compile(r"^\s*(?:\d{1,3}[.):]\s+|[-*•]\s+)")
|
test_memory_routes_session_owner.py and test_memory_owner_isolation.py via
|
||||||
|
``import ... as mr`` + ``setattr(mr, ...)``) all operate on the *same* object
|
||||||
|
the application actually uses. Keeps existing import paths working after
|
||||||
def _strip_list_prefix(text: str) -> str:
|
slice 2c (#4082/#4071). Source-introspection tests read the canonical file
|
||||||
if not text:
|
by path.
|
||||||
return text
|
|
||||||
return _LIST_PREFIX_RE.sub("", text, count=1).strip()
|
|
||||||
|
|
||||||
from services.memory import MemoryManager
|
|
||||||
from core.session_manager import SessionManager
|
|
||||||
from src.request_models import MemoryAddRequest
|
|
||||||
from core.database import SessionLocal
|
|
||||||
from src.llm_core import llm_call_async
|
|
||||||
from services.memory.memory_extractor import audit_memories
|
|
||||||
from src.auth_helpers import get_current_user, require_user
|
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
|
||||||
from src.task_endpoint import resolve_task_endpoint
|
|
||||||
from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
|
|
||||||
"""Set up memory-related routes."""
|
|
||||||
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
|
||||||
|
|
||||||
def _owner(request: Request) -> Optional[str]:
|
|
||||||
return get_current_user(request)
|
|
||||||
|
|
||||||
def _assert_session_owner(session_obj, user):
|
|
||||||
"""SECURITY: 404 if the caller does not own this session.
|
|
||||||
|
|
||||||
SessionManager.get_session is NOT owner-scoped — it returns any
|
|
||||||
session by id. These routes accept a caller-supplied session id, so
|
|
||||||
without this gate a user could target another tenant's session and
|
|
||||||
leak their chat history, their session-scoped LLM credentials, or the
|
|
||||||
session title. Mirrors session_routes / webhook_routes ownership.
|
|
||||||
"""
|
"""
|
||||||
if user is not None and getattr(session_obj, "owner", None) != user:
|
|
||||||
raise HTTPException(404, "Session not found")
|
|
||||||
|
|
||||||
def _verify_memory_owner(memory: dict, user: Optional[str]):
|
import sys as _sys
|
||||||
"""Raise 404 if user doesn't own this memory.
|
|
||||||
|
|
||||||
SECURITY: strict ownership — previously `mem_owner and mem_owner != user`
|
from routes.memory import memory_routes as _canonical # noqa: F401
|
||||||
allowed any user to read/edit/delete memories with an empty/null owner
|
|
||||||
field, which leaked legacy data across the multi-user deploy.
|
|
||||||
"""
|
|
||||||
if user is None:
|
|
||||||
return # Auth disabled
|
|
||||||
if memory.get("owner") != user:
|
|
||||||
raise HTTPException(404, "Memory not found")
|
|
||||||
|
|
||||||
@router.post("/debug")
|
_sys.modules[__name__] = _canonical
|
||||||
def debug_memory_relevance(request: Request, query: str = Form(...)):
|
|
||||||
"""Debug which memories would be triggered for a query"""
|
|
||||||
user = _owner(request)
|
|
||||||
memories = memory_manager.load(owner=user)
|
|
||||||
relevant = memory_manager.get_relevant_memories(query, memories, threshold=0.05)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"query": query,
|
|
||||||
"total_memories": len(memories),
|
|
||||||
"relevant_count": len(relevant),
|
|
||||||
"relevant_memories": [{"text": m["text"], "category": m.get("category", "unknown")}
|
|
||||||
for m in relevant]
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/add", response_model=Dict[str, Any])
|
|
||||||
async def api_add_memory(
|
|
||||||
request: Request,
|
|
||||||
memory_data: Optional[MemoryAddRequest] = None
|
|
||||||
):
|
|
||||||
"""Add a new memory entry with optional category, source, and session reference."""
|
|
||||||
from src.auth_helpers import require_privilege
|
|
||||||
require_privilege(request, "can_manage_memory")
|
|
||||||
if memory_data is None:
|
|
||||||
form = await request.form()
|
|
||||||
memory_data = MemoryAddRequest(
|
|
||||||
text=form.get("text"),
|
|
||||||
category=form.get("category", "fact"),
|
|
||||||
source=form.get("source", "user"),
|
|
||||||
session_id=form.get("session_id")
|
|
||||||
)
|
|
||||||
|
|
||||||
user = _owner(request)
|
|
||||||
text = (memory_data.text or "").strip()
|
|
||||||
if not text:
|
|
||||||
raise HTTPException(400, "empty memory")
|
|
||||||
user_mem = memory_manager.load(owner=user)
|
|
||||||
if memory_manager.find_duplicates(text, user_mem):
|
|
||||||
return {"ok": True, "count": len(user_mem), "message": "Memory already exists"}
|
|
||||||
|
|
||||||
if memory_data.session_id:
|
|
||||||
try:
|
|
||||||
session_obj = session_manager.get_session(memory_data.session_id)
|
|
||||||
except KeyError:
|
|
||||||
raise HTTPException(404, "Session not found")
|
|
||||||
_assert_session_owner(session_obj, user)
|
|
||||||
|
|
||||||
new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user)
|
|
||||||
if memory_data.session_id:
|
|
||||||
new_entry["session_id"] = memory_data.session_id
|
|
||||||
all_mem = memory_manager.load_all()
|
|
||||||
all_mem.append(new_entry)
|
|
||||||
memory_manager.save(all_mem)
|
|
||||||
# Sync vector index
|
|
||||||
if memory_vector and memory_vector.healthy:
|
|
||||||
memory_vector.add(new_entry["id"], text)
|
|
||||||
try:
|
|
||||||
from src.event_bus import fire_event
|
|
||||||
fire_event("memory_added", user)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("memory_added event dispatch failed", exc_info=True)
|
|
||||||
return {"ok": True, "count": len([m for m in all_mem if m.get("owner") == user])}
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def api_get_memory(request: Request):
|
|
||||||
"""Return all memory entries with their metadata."""
|
|
||||||
user = _owner(request)
|
|
||||||
return {"memory": memory_manager.load(owner=user)}
|
|
||||||
|
|
||||||
@router.post("/search")
|
|
||||||
def search_memories(request: Request, query: str = Form(...), session_id: str = Form(None), category: str = Form(None)):
|
|
||||||
"""Search across all memories with optional filters."""
|
|
||||||
user = _owner(request)
|
|
||||||
memories = memory_manager.load(owner=user)
|
|
||||||
|
|
||||||
if session_id:
|
|
||||||
memories = [m for m in memories if m.get("session_id") == session_id]
|
|
||||||
|
|
||||||
if category:
|
|
||||||
memories = [m for m in memories if category in m.get("categories", [m.get("category", "")])]
|
|
||||||
|
|
||||||
relevant = memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
|
|
||||||
|
|
||||||
return {"memories": relevant, "total": len(relevant), "query": query}
|
|
||||||
|
|
||||||
@router.get("/timeline")
|
|
||||||
def memory_timeline(request: Request):
|
|
||||||
"""Get memories in chronological order with source session information."""
|
|
||||||
user = _owner(request)
|
|
||||||
memories = memory_manager.load(owner=user)
|
|
||||||
sorted_memories = sorted(memories, key=lambda x: x.get("timestamp", 0), reverse=True)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for memory in sorted_memories:
|
|
||||||
if "timestamp" in memory:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromtimestamp(memory["timestamp"])
|
|
||||||
memory["timestamp_str"] = dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
except (ValueError, OSError, OverflowError):
|
|
||||||
memory["timestamp_str"] = "Unknown"
|
|
||||||
else:
|
|
||||||
memory["timestamp_str"] = "Unknown"
|
|
||||||
|
|
||||||
session_id = memory.get("session_id")
|
|
||||||
if session_id and session_id in session_manager.sessions:
|
|
||||||
try:
|
|
||||||
session = session_manager.get_session(session_id)
|
|
||||||
if session:
|
|
||||||
_assert_session_owner(session, user)
|
|
||||||
memory["session_name"] = session.name if session else f"Session {session_id[:6]}"
|
|
||||||
except KeyError:
|
|
||||||
memory["session_name"] = "Unknown"
|
|
||||||
except HTTPException as exc:
|
|
||||||
if exc.status_code != 404:
|
|
||||||
raise
|
|
||||||
memory["session_name"] = "Unknown"
|
|
||||||
else:
|
|
||||||
memory["session_name"] = "Unknown"
|
|
||||||
|
|
||||||
results.append(memory)
|
|
||||||
|
|
||||||
return {"timeline": results, "total": len(results)}
|
|
||||||
|
|
||||||
@router.get("/by-session/{session_id}")
|
|
||||||
def get_memory_by_session(request: Request, session_id: str):
|
|
||||||
"""Get all memories associated with a specific session."""
|
|
||||||
user = _owner(request)
|
|
||||||
try:
|
|
||||||
_session_obj = session_manager.get_session(session_id)
|
|
||||||
except KeyError:
|
|
||||||
raise HTTPException(404, f"Session {session_id} not found")
|
|
||||||
_assert_session_owner(_session_obj, user)
|
|
||||||
memories = memory_manager.load(owner=user)
|
|
||||||
session_memories = [m for m in memories if m.get("session_id") == session_id]
|
|
||||||
|
|
||||||
session_memories.sort(key=lambda x: x.get("timestamp", 0), reverse=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
session = session_manager.get_session(session_id)
|
|
||||||
session_name = session.name if session else f"Session {session_id[:6]}"
|
|
||||||
except KeyError:
|
|
||||||
session_name = f"Session {session_id[:6]}"
|
|
||||||
|
|
||||||
for memory in session_memories:
|
|
||||||
memory["session_name"] = session_name
|
|
||||||
|
|
||||||
return {
|
|
||||||
"session_id": session_id,
|
|
||||||
"session_name": session_name,
|
|
||||||
"memory_count": len(session_memories),
|
|
||||||
"memories": session_memories
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/extract")
|
|
||||||
async def extract_memory(request: Request, session: str = Form(...)) -> Dict[str, List[str]]:
|
|
||||||
"""Analyze a session's chat history and return memory suggestions."""
|
|
||||||
require_user(request)
|
|
||||||
try:
|
|
||||||
sess = session_manager.get_session(session)
|
|
||||||
except KeyError:
|
|
||||||
raise HTTPException(404, "Session not found")
|
|
||||||
_assert_session_owner(sess, _owner(request))
|
|
||||||
|
|
||||||
system_msg = {
|
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"You are a helpful assistant. Analyze the entire conversation history provided and extract any "
|
|
||||||
"useful factual statements, contacts, addresses, phone numbers, or other information that the user "
|
|
||||||
"might want to remember for future interactions. Return each piece of information as a JSON object "
|
|
||||||
"with a 'text' field. For example: [{'text': 'Alice lives at 123 Main St'}, {'text': 'Bob works at Acme Corp'}]. "
|
|
||||||
"Only include information that is specific and likely to be useful later."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
messages = [system_msg] + sess.get_context_messages()
|
|
||||||
|
|
||||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
|
||||||
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
suggestion_text = await llm_call_async(
|
|
||||||
t_url,
|
|
||||||
t_model,
|
|
||||||
messages,
|
|
||||||
temperature=0.2,
|
|
||||||
max_tokens=500,
|
|
||||||
headers=t_headers,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
suggestions = json.loads(suggestion_text)
|
|
||||||
if isinstance(suggestions, list):
|
|
||||||
suggestions = [s if isinstance(s, str) else s.get("text", "") for s in suggestions]
|
|
||||||
else:
|
|
||||||
suggestions = []
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
suggestions = [line.strip() for line in suggestion_text.splitlines() if line.strip()]
|
|
||||||
|
|
||||||
return {"suggestions": [s for s in suggestions if s]}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"LLM memory extraction failed (session {session}): {e}")
|
|
||||||
fallback = memory_manager.extract_memory_from_chat(sess.history, session)
|
|
||||||
return {"suggestions": [item["text"] for item in fallback]}
|
|
||||||
|
|
||||||
@router.post("/audit")
|
|
||||||
async def api_audit_memories(request: Request, session: str = Form(None)):
|
|
||||||
"""Deduplicate and consolidate memories via LLM.
|
|
||||||
|
|
||||||
Uses task/utility/default settings through the shared resolver, with
|
|
||||||
the active session as fallback when no task or utility model is set.
|
|
||||||
Returns before and after memory counts.
|
|
||||||
"""
|
|
||||||
user = _owner(request)
|
|
||||||
fallback_url = fallback_model = None
|
|
||||||
fallback_headers = None
|
|
||||||
if session:
|
|
||||||
try:
|
|
||||||
sess = session_manager.get_session(session)
|
|
||||||
_assert_session_owner(sess, user)
|
|
||||||
fallback_url = sess.endpoint_url
|
|
||||||
fallback_model = sess.model
|
|
||||||
fallback_headers = sess.headers
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
endpoint_url, model, headers = resolve_task_endpoint(
|
|
||||||
fallback_url, fallback_model, fallback_headers, owner=user
|
|
||||||
)
|
|
||||||
|
|
||||||
if not endpoint_url or not model:
|
|
||||||
raise HTTPException(400, "No default model configured — set one in Settings")
|
|
||||||
|
|
||||||
result = await audit_memories(
|
|
||||||
memory_manager,
|
|
||||||
memory_vector,
|
|
||||||
endpoint_url,
|
|
||||||
model,
|
|
||||||
headers,
|
|
||||||
owner=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "error" in result and "before" not in result:
|
|
||||||
raise HTTPException(502, f"Audit failed: {result['error']}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": "error" not in result,
|
|
||||||
"before": result.get("before", 0),
|
|
||||||
"after": result.get("after", 0),
|
|
||||||
"removed": result.get("before", 0) - result.get("after", 0),
|
|
||||||
# True when the audit skipped the LLM because nothing changed
|
|
||||||
# since the last tidy. Frontend already says "Already clean"
|
|
||||||
# for removed==0, so this is here for future use / debugging.
|
|
||||||
"already_tidy": bool(result.get("already_tidy")),
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/import")
|
|
||||||
async def import_memories_from_file(
|
|
||||||
request: Request,
|
|
||||||
session: str | None = Form(None),
|
|
||||||
file: UploadFile = File(...)
|
|
||||||
):
|
|
||||||
"""Extract memory suggestions from an uploaded file (PDF, TXT, MD, etc.)."""
|
|
||||||
from src.auth_helpers import require_privilege
|
|
||||||
require_privilege(request, "can_manage_memory")
|
|
||||||
|
|
||||||
endpoint_url = None
|
|
||||||
model = None
|
|
||||||
headers = {}
|
|
||||||
|
|
||||||
user = _owner(request)
|
|
||||||
|
|
||||||
if session:
|
|
||||||
try:
|
|
||||||
sess = session_manager.get_session(session)
|
|
||||||
_assert_session_owner(sess, user)
|
|
||||||
except KeyError:
|
|
||||||
sess = None
|
|
||||||
except HTTPException as exc:
|
|
||||||
if exc.status_code != 404:
|
|
||||||
raise
|
|
||||||
sess = None
|
|
||||||
|
|
||||||
if sess is None:
|
|
||||||
logger.warning("Session %s not found or inaccessible, falling back to utility endpoint", session)
|
|
||||||
endpoint_url, model, headers = resolve_endpoint("utility", owner=user)
|
|
||||||
else:
|
|
||||||
endpoint_url, model, headers = resolve_task_endpoint(
|
|
||||||
sess.endpoint_url, sess.model, sess.headers, owner=user
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
endpoint_url, model, headers = resolve_task_endpoint(owner=user)
|
|
||||||
|
|
||||||
if not endpoint_url or not model:
|
|
||||||
raise HTTPException(400, "No LLM model configured. Set a default model in Settings.")
|
|
||||||
|
|
||||||
content = await read_upload_limited(file, MEMORY_IMPORT_MAX_BYTES, "Memory import")
|
|
||||||
filename = file.filename or "upload"
|
|
||||||
_, ext = os.path.splitext(filename.lower())
|
|
||||||
|
|
||||||
allowed = {".txt", ".md", ".pdf", ".csv", ".log", ".json", ".py", ".js", ".html"}
|
|
||||||
if ext not in allowed:
|
|
||||||
raise HTTPException(400, f"Unsupported file type: {ext}")
|
|
||||||
|
|
||||||
# Extract text based on file type
|
|
||||||
if ext == ".pdf":
|
|
||||||
from src.document_processor import _process_pdf
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
|
||||||
tmp.write(content)
|
|
||||||
tmp_path = tmp.name
|
|
||||||
try:
|
|
||||||
text = _process_pdf(tmp_path, owner=_owner(request))
|
|
||||||
finally:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
text = content.decode("utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
from charset_normalizer import detect
|
|
||||||
encoding = (detect(content) or {}).get("encoding") or "utf-8"
|
|
||||||
text = content.decode(encoding, errors="replace")
|
|
||||||
|
|
||||||
if not text.strip():
|
|
||||||
return {"suggestions": [], "message": "No readable content found"}
|
|
||||||
|
|
||||||
# Fast path: a .json upload that already looks like a memories export
|
|
||||||
# (list of {text, category, ...} dicts, or list of strings) round-trips
|
|
||||||
# directly without spending an LLM call to re-extract its own output.
|
|
||||||
# Without this, re-importing a memories.json from another account
|
|
||||||
# ran the file through the extractor, which often re-emitted the
|
|
||||||
# entries as a numbered list (and the numbering leaked into the
|
|
||||||
# `text` field).
|
|
||||||
if ext == ".json":
|
|
||||||
try:
|
|
||||||
parsed = json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
parsed = None
|
|
||||||
if isinstance(parsed, list) and parsed:
|
|
||||||
direct = []
|
|
||||||
for item in parsed:
|
|
||||||
if isinstance(item, dict) and item.get("text"):
|
|
||||||
direct.append({
|
|
||||||
"text": _strip_list_prefix(str(item["text"])),
|
|
||||||
"category": item.get("category") or "fact",
|
|
||||||
})
|
|
||||||
elif isinstance(item, str) and item.strip():
|
|
||||||
direct.append({
|
|
||||||
"text": _strip_list_prefix(item.strip()),
|
|
||||||
"category": "fact",
|
|
||||||
})
|
|
||||||
if direct:
|
|
||||||
return {"suggestions": direct, "filename": filename}
|
|
||||||
|
|
||||||
# Truncate very long documents
|
|
||||||
if len(text) > 15000:
|
|
||||||
text = text[:15000] + "\n[Truncated]"
|
|
||||||
|
|
||||||
# Send to LLM for memory extraction
|
|
||||||
import_prompt = (
|
|
||||||
"You are a memory extraction assistant. The user uploaded a document. "
|
|
||||||
"Analyze the text below and extract specific, useful facts — things like "
|
|
||||||
"names, preferences, jobs, locations, relationships, opinions, projects, "
|
|
||||||
"goals, contacts, or any other personal details worth remembering.\n\n"
|
|
||||||
"Rules:\n"
|
|
||||||
"- Each fact should be a short, self-contained statement\n"
|
|
||||||
"- Do NOT extract generic knowledge\n"
|
|
||||||
"- Focus on personal, memorable information\n"
|
|
||||||
"- If there are no useful facts, return an empty array\n\n"
|
|
||||||
"Return a JSON array of objects with 'text' and 'category' fields.\n"
|
|
||||||
"Categories: 'identity', 'preference', 'fact', 'contact', 'project', 'goal'\n\n"
|
|
||||||
"Return ONLY valid JSON, no markdown fences."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw = await llm_call_async(
|
|
||||||
endpoint_url,
|
|
||||||
model,
|
|
||||||
[
|
|
||||||
{"role": "system", "content": import_prompt},
|
|
||||||
{"role": "user", "content": f"Document: {filename}\n\n{text}"},
|
|
||||||
],
|
|
||||||
temperature=0.2,
|
|
||||||
max_tokens=2000,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse JSON
|
|
||||||
raw = raw.strip()
|
|
||||||
if raw.startswith("```"):
|
|
||||||
raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
|
||||||
|
|
||||||
suggestions = json.loads(raw)
|
|
||||||
if isinstance(suggestions, list):
|
|
||||||
normalized = []
|
|
||||||
for s in suggestions:
|
|
||||||
if not s:
|
|
||||||
continue
|
|
||||||
if isinstance(s, dict):
|
|
||||||
s = dict(s)
|
|
||||||
if s.get("text"):
|
|
||||||
s["text"] = _strip_list_prefix(str(s["text"]))
|
|
||||||
normalized.append(s)
|
|
||||||
else:
|
|
||||||
normalized.append({"text": _strip_list_prefix(str(s)), "category": "fact"})
|
|
||||||
suggestions = normalized
|
|
||||||
else:
|
|
||||||
suggestions = []
|
|
||||||
|
|
||||||
return {"suggestions": suggestions, "filename": filename}
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Fallback: split by lines, stripping any "1.", "2)" markdown-list
|
|
||||||
# numbering the model added so saved memories don't keep the prefix.
|
|
||||||
lines = [_strip_list_prefix(l.strip()) for l in raw.splitlines() if l.strip() and len(l.strip()) > 5]
|
|
||||||
return {"suggestions": [{"text": l, "category": "fact"} for l in lines[:20]], "filename": filename}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Memory import extraction failed: {e}")
|
|
||||||
raise HTTPException(502, f"LLM extraction failed: {str(e)}")
|
|
||||||
|
|
||||||
@router.post("/{memory_id}/pin")
|
|
||||||
def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)):
|
|
||||||
"""Pin or unpin a memory. Pinned memories are always included in context."""
|
|
||||||
user = _owner(request)
|
|
||||||
all_mem = memory_manager.load_all()
|
|
||||||
for i, memory in enumerate(all_mem):
|
|
||||||
if memory["id"] == memory_id:
|
|
||||||
_verify_memory_owner(memory, user)
|
|
||||||
all_mem[i]["pinned"] = pinned
|
|
||||||
memory_manager.save(all_mem)
|
|
||||||
return {"ok": True, "pinned": pinned}
|
|
||||||
raise HTTPException(404, f"Memory item {memory_id} not found")
|
|
||||||
|
|
||||||
# Wildcard routes MUST come last — otherwise they swallow /import, /search, etc.
|
|
||||||
@router.get("/{memory_id}")
|
|
||||||
def get_memory_item(request: Request, memory_id: str):
|
|
||||||
"""Get a specific memory item by ID."""
|
|
||||||
user = _owner(request)
|
|
||||||
memories = memory_manager.load(owner=user)
|
|
||||||
for memory in memories:
|
|
||||||
if memory["id"] == memory_id:
|
|
||||||
return {"memory": memory}
|
|
||||||
|
|
||||||
raise HTTPException(404, "Memory not found")
|
|
||||||
|
|
||||||
@router.put("/{memory_id}")
|
|
||||||
def update_memory(request: Request, memory_id: str, text: str = Form(...), category: str = Form(None)):
|
|
||||||
"""Update an existing memory item with new text and optional category."""
|
|
||||||
user = _owner(request)
|
|
||||||
all_mem = memory_manager.load_all()
|
|
||||||
for i, memory in enumerate(all_mem):
|
|
||||||
if memory["id"] == memory_id:
|
|
||||||
_verify_memory_owner(memory, user)
|
|
||||||
all_mem[i]["text"] = text.strip()
|
|
||||||
if category:
|
|
||||||
all_mem[i]["category"] = category
|
|
||||||
all_mem[i]["timestamp"] = int(time.time())
|
|
||||||
|
|
||||||
memory_manager.save(all_mem)
|
|
||||||
# Sync vector index (remove old, add updated)
|
|
||||||
if memory_vector and memory_vector.healthy:
|
|
||||||
memory_vector.remove(memory_id)
|
|
||||||
memory_vector.add(memory_id, text.strip())
|
|
||||||
return {"ok": True, "message": "Memory updated successfully"}
|
|
||||||
|
|
||||||
raise HTTPException(404, f"Memory item {memory_id} not found")
|
|
||||||
|
|
||||||
@router.delete("/{memory_id}")
|
|
||||||
def delete_memory(request: Request, memory_id: str):
|
|
||||||
"""Delete a memory item by its ID."""
|
|
||||||
user = _owner(request)
|
|
||||||
all_mem = memory_manager.load_all()
|
|
||||||
|
|
||||||
# Find and verify ownership before deleting
|
|
||||||
target = next((m for m in all_mem if m["id"] == memory_id), None)
|
|
||||||
if not target:
|
|
||||||
raise HTTPException(404, f"Memory item {memory_id} not found")
|
|
||||||
_verify_memory_owner(target, user)
|
|
||||||
|
|
||||||
all_mem = [m for m in all_mem if m["id"] != memory_id]
|
|
||||||
memory_manager.save(all_mem)
|
|
||||||
# Sync vector index
|
|
||||||
if memory_vector and memory_vector.healthy:
|
|
||||||
memory_vector.remove(memory_id)
|
|
||||||
return {"ok": True, "message": "Memory deleted successfully"}
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|||||||
+174
-71
@@ -19,6 +19,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
||||||
from core.log_safety import redact_url as _redact_url_for_log
|
from core.log_safety import redact_url as _redact_url_for_log
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
|
from src.constants import COOKBOOK_STATE_FILE
|
||||||
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
|
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
|
||||||
from src.tls_overrides import llm_verify
|
from src.tls_overrides import llm_verify
|
||||||
from src.settings import load_settings as _load_settings, save_settings as _save_settings
|
from src.settings import load_settings as _load_settings, save_settings as _save_settings
|
||||||
@@ -112,6 +113,67 @@ def _clear_endpoint_settings_for_endpoint(settings: dict, ep_id: str, *, include
|
|||||||
return cleared
|
return cleared
|
||||||
|
|
||||||
|
|
||||||
|
_COOKBOOK_ACTIVE_SERVE_STATUSES = {
|
||||||
|
"starting", "loading", "ready", "running", "restarting",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _active_cookbook_endpoint_ids() -> set[str]:
|
||||||
|
"""Endpoint IDs owned by active Cookbook serve tasks.
|
||||||
|
|
||||||
|
Cookbook auto-registers endpoints with ids like ``local-*``. Those rows are
|
||||||
|
managed lifecycle state, not durable user configuration. If a tmux stream is
|
||||||
|
stopped or an old task lingers, the row must stop participating in model
|
||||||
|
selection and defaults.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not os.path.exists(COOKBOOK_STATE_FILE):
|
||||||
|
return set()
|
||||||
|
with open(COOKBOOK_STATE_FILE, "r", encoding="utf-8") as fh:
|
||||||
|
raw = fh.read()
|
||||||
|
state = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
out: set[str] = set()
|
||||||
|
for task in state.get("tasks") or []:
|
||||||
|
if not isinstance(task, dict) or task.get("type") != "serve":
|
||||||
|
continue
|
||||||
|
if str(task.get("status") or "").lower() not in _COOKBOOK_ACTIVE_SERVE_STATUSES:
|
||||||
|
continue
|
||||||
|
ep_id = task.get("_endpointId") or task.get("endpointId") or task.get("endpoint_id")
|
||||||
|
if ep_id:
|
||||||
|
out.add(str(ep_id))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_stale_cookbook_local_endpoints(db) -> int:
|
||||||
|
"""Disable enabled cookbook endpoints whose serve task is no longer active."""
|
||||||
|
active_ids = _active_cookbook_endpoint_ids()
|
||||||
|
if not active_ids:
|
||||||
|
return 0
|
||||||
|
stale = (
|
||||||
|
db.query(ModelEndpoint)
|
||||||
|
.filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||||
|
.filter(ModelEndpoint.id.like("local-%"))
|
||||||
|
.filter(~ModelEndpoint.id.in_(active_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not stale:
|
||||||
|
return 0
|
||||||
|
settings = _load_settings()
|
||||||
|
touched_settings = False
|
||||||
|
for ep in stale:
|
||||||
|
ep.is_enabled = False
|
||||||
|
ep.model_refresh_mode = "disabled"
|
||||||
|
if _clear_endpoint_settings_for_endpoint(settings, ep.id):
|
||||||
|
touched_settings = True
|
||||||
|
logger.info("Disabled stale Cookbook endpoint %s (%s @ %s)", ep.id, ep.name, ep.base_url)
|
||||||
|
if touched_settings:
|
||||||
|
_save_settings(settings)
|
||||||
|
db.commit()
|
||||||
|
return len(stale)
|
||||||
|
|
||||||
|
|
||||||
def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
|
def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
|
||||||
"""Remove endpoint references from scoped or legacy-flat user preferences."""
|
"""Remove endpoint references from scoped or legacy-flat user preferences."""
|
||||||
if not isinstance(all_prefs, dict):
|
if not isinstance(all_prefs, dict):
|
||||||
@@ -125,7 +187,24 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
|
|||||||
return cleared_users
|
return cleared_users
|
||||||
|
|
||||||
|
|
||||||
def _default_endpoint_needs_assignment(current_default_id: str, enabled_endpoint_ids) -> bool:
|
def _endpoint_visible_model_ids(ep: Any) -> List[str]:
|
||||||
|
"""Known visible model ids for an endpoint, including pinned/manual ids."""
|
||||||
|
if ep is None:
|
||||||
|
return []
|
||||||
|
return _visible_models(
|
||||||
|
getattr(ep, "cached_models", None),
|
||||||
|
getattr(ep, "hidden_models", None),
|
||||||
|
getattr(ep, "pinned_models", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_endpoint_needs_assignment(
|
||||||
|
current_default_id: str,
|
||||||
|
enabled_endpoint_ids,
|
||||||
|
*,
|
||||||
|
current_default_endpoint: Any = None,
|
||||||
|
current_default_model: str = "",
|
||||||
|
) -> bool:
|
||||||
"""Whether the global default chat endpoint should be (re)assigned.
|
"""Whether the global default chat endpoint should be (re)assigned.
|
||||||
|
|
||||||
True when nothing is configured yet, or the configured default no longer
|
True when nothing is configured yet, or the configured default no longer
|
||||||
@@ -137,7 +216,14 @@ def _default_endpoint_needs_assignment(current_default_id: str, enabled_endpoint
|
|||||||
"""
|
"""
|
||||||
if not current_default_id:
|
if not current_default_id:
|
||||||
return True
|
return True
|
||||||
return current_default_id not in enabled_endpoint_ids
|
if current_default_id not in enabled_endpoint_ids:
|
||||||
|
return True
|
||||||
|
if current_default_endpoint is None:
|
||||||
|
return False
|
||||||
|
if not (current_default_model or "").strip():
|
||||||
|
return True
|
||||||
|
visible = _endpoint_visible_model_ids(current_default_endpoint)
|
||||||
|
return bool(visible and current_default_model not in visible)
|
||||||
|
|
||||||
|
|
||||||
# Loopback hosts a user might type for a local model server (LM Studio,
|
# Loopback hosts a user might type for a local model server (LM Studio,
|
||||||
@@ -1066,6 +1152,36 @@ def _merge_model_ids(*lists):
|
|||||||
return out
|
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):
|
def _visible_models(cached_models, hidden_models, pinned_models=None):
|
||||||
"""Merge cached + pinned model IDs, then filter out hidden ones.
|
"""Merge cached + pinned model IDs, then filter out hidden ones.
|
||||||
|
|
||||||
@@ -1079,6 +1195,7 @@ def _visible_models(cached_models, hidden_models, pinned_models=None):
|
|||||||
_normalize_model_ids(cached_models),
|
_normalize_model_ids(cached_models),
|
||||||
_normalize_model_ids(pinned_models),
|
_normalize_model_ids(pinned_models),
|
||||||
)
|
)
|
||||||
|
merged = _filter_mlx_deepseek_v4_repo_when_shimmed(merged)
|
||||||
if not hidden_models:
|
if not hidden_models:
|
||||||
return merged
|
return merged
|
||||||
hidden = set(_normalize_model_ids(hidden_models))
|
hidden = set(_normalize_model_ids(hidden_models))
|
||||||
@@ -1194,6 +1311,8 @@ def setup_model_routes(model_discovery):
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
changed = False
|
changed = False
|
||||||
try:
|
try:
|
||||||
|
if _disable_stale_cookbook_local_endpoints(db):
|
||||||
|
changed = True
|
||||||
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||||
now = _time.time()
|
now = _time.time()
|
||||||
groups: Dict[str, Dict[str, Any]] = {}
|
groups: Dict[str, Dict[str, Any]] = {}
|
||||||
@@ -1267,6 +1386,8 @@ def setup_model_routes(model_discovery):
|
|||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
if _disable_stale_cookbook_local_endpoints(db):
|
||||||
|
_invalidate_models_cache()
|
||||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||||
if owner and not is_admin:
|
if owner and not is_admin:
|
||||||
# Regular users see: their own endpoints + null-owner
|
# Regular users see: their own endpoints + null-owner
|
||||||
@@ -1306,9 +1427,9 @@ def setup_model_routes(model_discovery):
|
|||||||
"port": 0,
|
"port": 0,
|
||||||
"url": chat_url,
|
"url": chat_url,
|
||||||
"models": curated,
|
"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": 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_id": ep.id,
|
||||||
"endpoint_name": ep.name,
|
"endpoint_name": ep.name,
|
||||||
"category": category,
|
"category": category,
|
||||||
@@ -1336,7 +1457,7 @@ def setup_model_routes(model_discovery):
|
|||||||
return {"hosts": [], "items": items}
|
return {"hosts": [], "items": items}
|
||||||
|
|
||||||
@router.get("/models")
|
@router.get("/models")
|
||||||
def api_models(request: Request, refresh: bool = False):
|
def api_models(request: Request, refresh: bool = False, background: bool = False):
|
||||||
"""Get available models — per-user (caller sees only their endpoints +
|
"""Get available models — per-user (caller sees only their endpoints +
|
||||||
legacy/shared null-owner rows). Cached per-user for 30s."""
|
legacy/shared null-owner rows). Cached per-user for 30s."""
|
||||||
# Require auth; "" is the unconfigured single-user mode, treated as
|
# Require auth; "" is the unconfigured single-user mode, treated as
|
||||||
@@ -1378,7 +1499,10 @@ def setup_model_routes(model_discovery):
|
|||||||
return cache_entry["data"]
|
return cache_entry["data"]
|
||||||
result = _fetch_models(owner=owner, is_admin=_is_admin)
|
result = _fetch_models(owner=owner, is_admin=_is_admin)
|
||||||
_models_cache[_cache_key] = {"data": result, "time": now}
|
_models_cache[_cache_key] = {"data": result, "time": now}
|
||||||
# Kick off background refresh to update caches from live endpoints
|
# Kick off background refresh to update caches from live endpoints.
|
||||||
|
# Page boot can opt out with background=false so opening Odysseus does
|
||||||
|
# not start endpoint probes against slow/offline model servers.
|
||||||
|
if background or refresh:
|
||||||
_refresh_caches_bg(force=refresh)
|
_refresh_caches_bg(force=refresh)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -1388,6 +1512,7 @@ def setup_model_routes(model_discovery):
|
|||||||
# within ~8s of the user noticing.
|
# within ~8s of the user noticing.
|
||||||
_LOCAL_PROBE_TTL = 8.0
|
_LOCAL_PROBE_TTL = 8.0
|
||||||
_local_probe_cache: Dict[str, Any] = {"data": None, "time": 0.0}
|
_local_probe_cache: Dict[str, Any] = {"data": None, "time": 0.0}
|
||||||
|
_local_probe_inflight: Dict[str, Any] = {"task": None}
|
||||||
|
|
||||||
@router.get("/model-endpoints/probe-local")
|
@router.get("/model-endpoints/probe-local")
|
||||||
async def probe_local_endpoints(request: Request):
|
async def probe_local_endpoints(request: Request):
|
||||||
@@ -1402,8 +1527,16 @@ def setup_model_routes(model_discovery):
|
|||||||
(now - _local_probe_cache["time"]) < _LOCAL_PROBE_TTL):
|
(now - _local_probe_cache["time"]) < _LOCAL_PROBE_TTL):
|
||||||
return _local_probe_cache["data"]
|
return _local_probe_cache["data"]
|
||||||
|
|
||||||
|
import asyncio as _asyncio
|
||||||
|
task = _local_probe_inflight.get("task")
|
||||||
|
if task is not None and not task.done():
|
||||||
|
return await task
|
||||||
|
|
||||||
|
async def _compute_local_probe() -> Dict[str, Any]:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
if _disable_stale_cookbook_local_endpoints(db):
|
||||||
|
_invalidate_models_cache()
|
||||||
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||||
local_eps = []
|
local_eps = []
|
||||||
for ep in endpoints:
|
for ep in endpoints:
|
||||||
@@ -1422,7 +1555,6 @@ def setup_model_routes(model_discovery):
|
|||||||
async def _probe_one(data: Dict[str, Any]) -> Dict[str, Any]:
|
async def _probe_one(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
t0 = _time.time()
|
t0 = _time.time()
|
||||||
try:
|
try:
|
||||||
import asyncio as _asyncio
|
|
||||||
# Bumped 1.5s → 3.5s. The previous 1.5s budget was clipping
|
# Bumped 1.5s → 3.5s. The previous 1.5s budget was clipping
|
||||||
# local vLLM endpoints on Tailscale links where the model
|
# local vLLM endpoints on Tailscale links where the model
|
||||||
# server is still loading (Qwen3.5-122B takes 2–3 min to
|
# server is still loading (Qwen3.5-122B takes 2–3 min to
|
||||||
@@ -1441,7 +1573,6 @@ def setup_model_routes(model_discovery):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"alive": False, "latency_ms": None, "status_code": None, "error": str(e)[:120]}
|
return {"alive": False, "latency_ms": None, "status_code": None, "error": str(e)[:120]}
|
||||||
|
|
||||||
import asyncio as _asyncio
|
|
||||||
results_list = await _asyncio.gather(
|
results_list = await _asyncio.gather(
|
||||||
*[_probe_one(data) for data in grouped.values()],
|
*[_probe_one(data) for data in grouped.values()],
|
||||||
return_exceptions=False,
|
return_exceptions=False,
|
||||||
@@ -1452,9 +1583,17 @@ def setup_model_routes(model_discovery):
|
|||||||
results[eid] = r
|
results[eid] = r
|
||||||
|
|
||||||
_local_probe_cache["data"] = results
|
_local_probe_cache["data"] = results
|
||||||
_local_probe_cache["time"] = now
|
_local_probe_cache["time"] = _time.time()
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
task = _asyncio.create_task(_compute_local_probe())
|
||||||
|
_local_probe_inflight["task"] = task
|
||||||
|
try:
|
||||||
|
return await task
|
||||||
|
finally:
|
||||||
|
if _local_probe_inflight.get("task") is task:
|
||||||
|
_local_probe_inflight["task"] = None
|
||||||
|
|
||||||
@router.get("/ping")
|
@router.get("/ping")
|
||||||
def ping_endpoints(request: Request):
|
def ping_endpoints(request: Request):
|
||||||
"""Probe all enabled endpoints and return status + latency."""
|
"""Probe all enabled endpoints and return status + latency."""
|
||||||
@@ -1636,6 +1775,8 @@ def setup_model_routes(model_discovery):
|
|||||||
require_admin(request)
|
require_admin(request)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
if _disable_stale_cookbook_local_endpoints(db):
|
||||||
|
_invalidate_models_cache()
|
||||||
rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all()
|
rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all()
|
||||||
results = []
|
results = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -1643,67 +1784,11 @@ def setup_model_routes(model_discovery):
|
|||||||
hidden = _hidden_model_ids(r)
|
hidden = _hidden_model_ids(r)
|
||||||
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
|
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
|
||||||
visible = _visible_models(all_models, r.hidden_models, pinned)
|
visible = _visible_models(all_models, r.hidden_models, pinned)
|
||||||
# Endpoint counts as reachable if it has any model — including
|
# Keep the list route cache-only. It feeds Settings →
|
||||||
# admin-pinned IDs that a probe would never surface.
|
# Added Models and must render immediately; explicit
|
||||||
status = "online" if (all_models or pinned) else "offline"
|
# Refresh/Probe endpoints do the network work.
|
||||||
|
status = "online" if (all_models or pinned) else ("empty" if r.is_enabled else "offline")
|
||||||
ping = None
|
ping = None
|
||||||
# When cached_models is empty, do a quick reachability probe.
|
|
||||||
# Bumped 1.0s → 3.5s because the user reported endpoints they
|
|
||||||
# were ACTIVELY chatting with showed "offline" — the previous
|
|
||||||
# 1s timeout was clipping live cloud endpoints (DeepSeek can
|
|
||||||
# take 1.5–2.5s on /v1/models when their region is under load,
|
|
||||||
# vLLM on a remote GPU box behind SSH can also push past 1s).
|
|
||||||
# 3.5s still keeps the picker render snappy in the common
|
|
||||||
# "everything's already cached" path because this branch only
|
|
||||||
# runs for endpoints with an empty cached_models.
|
|
||||||
if not all_models and not pinned and r.is_enabled:
|
|
||||||
base_for_ping = _normalize_base(r.base_url)
|
|
||||||
kind_for_ping = _effective_endpoint_kind(r, base_for_ping)
|
|
||||||
ping_timeout = 10.0 if _classify_endpoint(base_for_ping, kind_for_ping) == "local" else 3.5
|
|
||||||
ping = _ping_endpoint(r.base_url, r.api_key, timeout=ping_timeout)
|
|
||||||
if ping.get("reachable"):
|
|
||||||
status = "loading" if ping.get("loading") else "empty"
|
|
||||||
if ping.get("loading"):
|
|
||||||
base = _normalize_base(r.base_url)
|
|
||||||
kind = _effective_endpoint_kind(r, base)
|
|
||||||
results.append({
|
|
||||||
"id": r.id,
|
|
||||||
"name": r.name,
|
|
||||||
"base_url": r.base_url,
|
|
||||||
"has_key": bool(r.api_key),
|
|
||||||
"api_key_fingerprint": _api_key_fingerprint(r.api_key),
|
|
||||||
"is_enabled": r.is_enabled,
|
|
||||||
"models": visible,
|
|
||||||
"pinned_models": pinned,
|
|
||||||
"hidden_count": len(hidden),
|
|
||||||
"online": True,
|
|
||||||
"status": status,
|
|
||||||
"ping_error": (ping or {}).get("error") if ping else None,
|
|
||||||
"model_type": getattr(r, "model_type", None) or "llm",
|
|
||||||
"supports_tools": getattr(r, "supports_tools", None),
|
|
||||||
"endpoint_kind": kind,
|
|
||||||
"category": _classify_endpoint(base, kind),
|
|
||||||
"model_refresh_mode": _endpoint_refresh_mode(r, kind),
|
|
||||||
"model_refresh_interval": getattr(r, "model_refresh_interval", None),
|
|
||||||
"model_refresh_timeout": getattr(r, "model_refresh_timeout", None),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
# Best-effort: if the probe came back reachable, try
|
|
||||||
# to populate cached_models in the background so the
|
|
||||||
# NEXT picker load shows "online" instead of "empty".
|
|
||||||
# Failure here is silent — we already returned the
|
|
||||||
# "empty" status, and the existing background refresh
|
|
||||||
# path will eventually fill it in too.
|
|
||||||
try:
|
|
||||||
probed = _probe_endpoint(r.base_url, r.api_key, timeout=max(5, int(ping_timeout)))
|
|
||||||
if probed:
|
|
||||||
r.cached_models = json.dumps(probed)
|
|
||||||
db.commit()
|
|
||||||
all_models = probed
|
|
||||||
visible = _visible_models(all_models, r.hidden_models, pinned)
|
|
||||||
status = "online"
|
|
||||||
except Exception as _refill_err:
|
|
||||||
logger.debug(f"opportunistic cached_models refill failed for {r.id}: {_refill_err!r}")
|
|
||||||
base = _normalize_base(r.base_url)
|
base = _normalize_base(r.base_url)
|
||||||
kind = _effective_endpoint_kind(r, base)
|
kind = _effective_endpoint_kind(r, base)
|
||||||
results.append({
|
results.append({
|
||||||
@@ -1833,7 +1918,14 @@ def setup_model_routes(model_discovery):
|
|||||||
if api_key.strip() and not existing.api_key:
|
if api_key.strip() and not existing.api_key:
|
||||||
existing.api_key = api_key.strip()
|
existing.api_key = api_key.strip()
|
||||||
changed = True
|
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(
|
probed_models = _probe_endpoint(
|
||||||
base_url,
|
base_url,
|
||||||
(api_key.strip() or existing.api_key or None),
|
(api_key.strip() or existing.api_key or None),
|
||||||
@@ -1920,7 +2012,18 @@ def setup_model_routes(model_discovery):
|
|||||||
ModelEndpoint.is_enabled == True # noqa: E712
|
ModelEndpoint.is_enabled == True # noqa: E712
|
||||||
).all()
|
).all()
|
||||||
}
|
}
|
||||||
if _default_endpoint_needs_assignment(settings.get("default_endpoint_id") or "", enabled_ids):
|
current_default_id = settings.get("default_endpoint_id") or ""
|
||||||
|
current_default_ep = None
|
||||||
|
if current_default_id:
|
||||||
|
current_default_ep = db.query(ModelEndpoint).filter(
|
||||||
|
ModelEndpoint.id == current_default_id
|
||||||
|
).first()
|
||||||
|
if _default_endpoint_needs_assignment(
|
||||||
|
current_default_id,
|
||||||
|
enabled_ids,
|
||||||
|
current_default_endpoint=current_default_ep,
|
||||||
|
current_default_model=settings.get("default_model") or "",
|
||||||
|
):
|
||||||
from src.endpoint_resolver import _first_chat_model
|
from src.endpoint_resolver import _first_chat_model
|
||||||
settings["default_endpoint_id"] = ep.id
|
settings["default_endpoint_id"] = ep.id
|
||||||
settings["default_model"] = _first_chat_model(model_ids) or ""
|
settings["default_model"] = _first_chat_model(model_ids) or ""
|
||||||
|
|||||||
@@ -483,6 +483,17 @@ async def dispatch_reminder(
|
|||||||
api_key = intg.get("api_key", "")
|
api_key = intg.get("api_key", "")
|
||||||
if api_key:
|
if api_key:
|
||||||
hdrs["Authorization"] = f"Bearer {api_key}"
|
hdrs["Authorization"] = f"Bearer {api_key}"
|
||||||
|
# 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:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
|
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
|
||||||
ntfy_sent = resp.is_success
|
ntfy_sent = resp.is_success
|
||||||
|
|||||||
@@ -20,6 +20,55 @@ from src.constants import DEEP_RESEARCH_DIR
|
|||||||
|
|
||||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_session_id(session_id: str) -> str:
|
||||||
|
if not _SESSION_ID_RE.fullmatch(session_id):
|
||||||
|
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:
|
||||||
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Model-name substrings that are NOT chat/generation models — research must
|
# Model-name substrings that are NOT chat/generation models — research must
|
||||||
@@ -31,6 +80,54 @@ _NON_CHAT_MODEL = (
|
|||||||
"moderation", "rerank", "reranker", "clip", "stable-diffusion",
|
"moderation", "rerank", "reranker", "clip", "stable-diffusion",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_RESEARCH_IMAGE_BLOCKLIST = {
|
||||||
|
"cdn.shopify.com/s/files/1/0179/4388/7926/files/icon.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_research_icon_or_logo_url(url: str) -> bool:
|
||||||
|
path = url.lower().split("?")[0]
|
||||||
|
return any(token in path for token in (
|
||||||
|
"/logo", "logo_", "-logo", "favicon", "apple-touch-icon",
|
||||||
|
"sprite", "icon-", "_icon", "/icons/", "badge",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _research_thumbnail(data: dict) -> str:
|
||||||
|
"""Pick the same first visible image the visual report uses as hero."""
|
||||||
|
hidden = set(data.get("hidden_images") or [])
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
def usable(image: str) -> bool:
|
||||||
|
image = str(image or "").strip()
|
||||||
|
if not image or image in seen or image in hidden:
|
||||||
|
return False
|
||||||
|
if not image.startswith("https://"):
|
||||||
|
return False
|
||||||
|
if image.endswith((".svg", ".ico", ".gif")):
|
||||||
|
return False
|
||||||
|
if any(blocked in image for blocked in _RESEARCH_IMAGE_BLOCKLIST):
|
||||||
|
return False
|
||||||
|
if _is_research_icon_or_logo_url(image):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
for source in data.get("sources") or []:
|
||||||
|
if not isinstance(source, dict):
|
||||||
|
continue
|
||||||
|
image = str(source.get("image") or source.get("og_image") or "").strip()
|
||||||
|
if usable(image):
|
||||||
|
seen.add(image)
|
||||||
|
return image
|
||||||
|
for finding in data.get("raw_findings") or data.get("findings") or []:
|
||||||
|
if not isinstance(finding, dict):
|
||||||
|
continue
|
||||||
|
image = str(finding.get("image") or finding.get("og_image") or "").strip()
|
||||||
|
if usable(image):
|
||||||
|
seen.add(image)
|
||||||
|
return image
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _first_chat_model(models) -> str:
|
def _first_chat_model(models) -> str:
|
||||||
"""First model that isn't an embedding/tts/etc. — falls back to models[0]."""
|
"""First model that isn't an embedding/tts/etc. — falls back to models[0]."""
|
||||||
@@ -124,10 +221,6 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
raise HTTPException(401, "Not authenticated")
|
raise HTTPException(401, "Not authenticated")
|
||||||
return user
|
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:
|
def _owns_in_memory(session_id: str, user: str) -> bool:
|
||||||
"""Ownership check for an in-flight (in-memory) research task.
|
"""Ownership check for an in-flight (in-memory) research task.
|
||||||
Falls back to the on-disk JSON if the task has already finished."""
|
Falls back to the on-disk JSON if the task has already finished."""
|
||||||
@@ -135,14 +228,34 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
if entry is not None:
|
if entry is not None:
|
||||||
return entry.get("owner", "") == user
|
return entry.get("owner", "") == user
|
||||||
# Task no longer in memory — check the persisted JSON.
|
# Task no longer in memory — check the persisted JSON.
|
||||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
|
||||||
if not path.exists():
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
return json.loads(path.read_text(encoding="utf-8")).get("owner") == user
|
return _find_owned_research_path(session_id, user) is not None
|
||||||
except Exception:
|
except HTTPException:
|
||||||
return False
|
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")
|
@router.get("/api/research/active")
|
||||||
async def research_active(request: Request):
|
async def research_active(request: Request):
|
||||||
"""List all currently active (running) research tasks."""
|
"""List all currently active (running) research tasks."""
|
||||||
@@ -199,9 +312,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
def _assert_owns_research(session_id: str, user: str) -> None:
|
def _assert_owns_research(session_id: str, user: str) -> None:
|
||||||
"""404-not-403 ownership gate for a research session's on-disk JSON.
|
"""404-not-403 ownership gate for a research session's on-disk JSON.
|
||||||
Use BEFORE returning any data or mutating the file."""
|
Use BEFORE returning any data or mutating the file."""
|
||||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
path = _require_research_path(session_id)
|
||||||
if not path.exists():
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
try:
|
try:
|
||||||
owner = json.loads(path.read_text(encoding="utf-8")).get("owner")
|
owner = json.loads(path.read_text(encoding="utf-8")).get("owner")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -290,6 +401,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
"started_at": d.get("started_at", 0),
|
"started_at": d.get("started_at", 0),
|
||||||
"completed_at": d.get("completed_at", 0),
|
"completed_at": d.get("completed_at", 0),
|
||||||
"archived": bool(d.get("archived")),
|
"archived": bool(d.get("archived")),
|
||||||
|
"thumbnail": _research_thumbnail(d),
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
@@ -312,9 +424,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
summary, stats — used by the Library preview panel."""
|
summary, stats — used by the Library preview panel."""
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
_validate_session_id(session_id)
|
_validate_session_id(session_id)
|
||||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
path = _require_research_path(session_id)
|
||||||
if not path.exists():
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -329,9 +439,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
"""Soft-archive / restore a research report (sets `archived` in its JSON)."""
|
"""Soft-archive / restore a research report (sets `archived` in its JSON)."""
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
_validate_session_id(session_id)
|
_validate_session_id(session_id)
|
||||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
path = _require_research_path(session_id)
|
||||||
if not path.exists():
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
if data.get("owner") != user:
|
if data.get("owner") != user:
|
||||||
@@ -349,10 +457,9 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
"""Delete a research result from disk."""
|
"""Delete a research result from disk."""
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
_validate_session_id(session_id)
|
_validate_session_id(session_id)
|
||||||
data_dir = Path(DEEP_RESEARCH_DIR)
|
json_path = _find_research_path(session_id)
|
||||||
json_path = data_dir / f"{session_id}.json"
|
|
||||||
deleted = False
|
deleted = False
|
||||||
if json_path.exists():
|
if json_path is not None:
|
||||||
# SECURITY: verify ownership before letting the caller delete it.
|
# SECURITY: verify ownership before letting the caller delete it.
|
||||||
try:
|
try:
|
||||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||||
@@ -508,12 +615,11 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
"""Get research result without clearing it (for panel use)."""
|
"""Get research result without clearing it (for panel use)."""
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
_validate_session_id(session_id)
|
_validate_session_id(session_id)
|
||||||
if not _owns_in_memory(session_id, user):
|
owned_disk_path = _require_owned_or_active_research_path(session_id, user)
|
||||||
raise HTTPException(404, "No research found for this session")
|
|
||||||
result = research_handler.get_result(session_id)
|
result = research_handler.get_result(session_id)
|
||||||
if result is None:
|
if result is None:
|
||||||
p = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
p = owned_disk_path
|
||||||
if p.exists():
|
if p is not None:
|
||||||
d = json.loads(p.read_text(encoding="utf-8"))
|
d = json.loads(p.read_text(encoding="utf-8"))
|
||||||
return {
|
return {
|
||||||
"result": d.get("result", ""),
|
"result": d.get("result", ""),
|
||||||
@@ -542,8 +648,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
# otherwise any authenticated user could spin off (and thereby read)
|
# otherwise any authenticated user could spin off (and thereby read)
|
||||||
# another user's report by guessing its session ID. Mirrors every other
|
# another user's report by guessing its session ID. Mirrors every other
|
||||||
# endpoint in this file (see result_peek above).
|
# endpoint in this file (see result_peek above).
|
||||||
if not _owns_in_memory(session_id, user):
|
owned_disk_path = _require_owned_or_active_research_path(session_id, user)
|
||||||
raise HTTPException(404, "No research found for this session")
|
|
||||||
if session_manager is None:
|
if session_manager is None:
|
||||||
raise HTTPException(500, "session_manager not configured")
|
raise HTTPException(500, "session_manager not configured")
|
||||||
|
|
||||||
@@ -552,8 +657,8 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
sources = research_handler.get_sources(session_id) or []
|
sources = research_handler.get_sources(session_id) or []
|
||||||
query = ""
|
query = ""
|
||||||
|
|
||||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
path = owned_disk_path
|
||||||
if path.exists():
|
if path is not None:
|
||||||
try:
|
try:
|
||||||
disk = json.loads(path.read_text(encoding="utf-8"))
|
disk = json.loads(path.read_text(encoding="utf-8"))
|
||||||
if not result:
|
if not result:
|
||||||
|
|||||||
+13
-12
@@ -162,7 +162,7 @@ def _persist_session_headers(session_id: str, headers: dict | None) -> None:
|
|||||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||||
if db_session:
|
if db_session:
|
||||||
db_session.headers = headers or {}
|
db_session.headers = headers or {}
|
||||||
db_session.updated_at = datetime.utcnow()
|
db_session.updated_at = utcnow_naive()
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
@@ -207,6 +207,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
"""Setup session routes with the provided manager and config"""
|
"""Setup session routes with the provided manager and config"""
|
||||||
|
|
||||||
REQUEST_TIMEOUT = config.get("REQUEST_TIMEOUT", 20)
|
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")
|
OPENAI_API_KEY = config.get("OPENAI_API_KEY")
|
||||||
SESSIONS_FILE = config.get("SESSIONS_FILE")
|
SESSIONS_FILE = config.get("SESSIONS_FILE")
|
||||||
|
|
||||||
@@ -223,8 +224,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
# purge exists only to catch ghosts the frontend missed (tab close,
|
# purge exists only to catch ghosts the frontend missed (tab close,
|
||||||
# crash). Only clean up rows old enough to be definitely orphaned.
|
# crash). Only clean up rows old enough to be definitely orphaned.
|
||||||
try:
|
try:
|
||||||
from datetime import datetime as _dt, timedelta as _td
|
from datetime import timedelta as _td
|
||||||
_cutoff = _dt.utcnow() - _td(minutes=10)
|
_cutoff = utcnow_naive() - _td(minutes=10)
|
||||||
_purge_db = SessionLocal()
|
_purge_db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
from core.database import ChatMessage as _DbMsg
|
from core.database import ChatMessage as _DbMsg
|
||||||
@@ -374,7 +375,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
from src.llm_core import list_model_ids
|
from src.llm_core import list_model_ids
|
||||||
ids = list_model_ids(
|
ids = list_model_ids(
|
||||||
endpoint_url,
|
endpoint_url,
|
||||||
timeout=REQUEST_TIMEOUT,
|
timeout=SESSION_MODEL_VALIDATION_TIMEOUT,
|
||||||
headers=validation_headers,
|
headers=validation_headers,
|
||||||
owner=user,
|
owner=user,
|
||||||
endpoint_id=endpoint_id.strip() if endpoint_id else None,
|
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("/"))
|
req_base = _os.path.basename(model_to_use.rstrip("/"))
|
||||||
avail = list_model_ids(
|
avail = list_model_ids(
|
||||||
endpoint_url,
|
endpoint_url,
|
||||||
timeout=REQUEST_TIMEOUT,
|
timeout=SESSION_MODEL_VALIDATION_TIMEOUT,
|
||||||
headers=validation_headers,
|
headers=validation_headers,
|
||||||
owner=user,
|
owner=user,
|
||||||
endpoint_id=endpoint_id.strip() if endpoint_id else None,
|
endpoint_id=endpoint_id.strip() if endpoint_id else None,
|
||||||
@@ -470,7 +471,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
db_session = db.query(DbSession).filter(DbSession.id == sid).first()
|
db_session = db.query(DbSession).filter(DbSession.id == sid).first()
|
||||||
if db_session:
|
if db_session:
|
||||||
db_session.folder = folder if folder else None
|
db_session.folder = folder if folder else None
|
||||||
db_session.updated_at = datetime.utcnow()
|
db_session.updated_at = utcnow_naive()
|
||||||
db.commit()
|
db.commit()
|
||||||
result["folder"] = folder if folder else None
|
result["folder"] = folder if folder else None
|
||||||
finally:
|
finally:
|
||||||
@@ -517,7 +518,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
db_session.model = model
|
db_session.model = model
|
||||||
db_session.endpoint_url = endpoint_url
|
db_session.endpoint_url = endpoint_url
|
||||||
db_session.headers = session.headers or {}
|
db_session.headers = session.headers or {}
|
||||||
db_session.updated_at = datetime.utcnow()
|
db_session.updated_at = utcnow_naive()
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -646,7 +647,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
db_session = db.query(DbSession).filter(DbSession.id == sid).first()
|
db_session = db.query(DbSession).filter(DbSession.id == sid).first()
|
||||||
if db_session:
|
if db_session:
|
||||||
db_session.archived = True
|
db_session.archived = True
|
||||||
db_session.updated_at = datetime.utcnow()
|
db_session.updated_at = utcnow_naive()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Update in memory if it exists
|
# Update in memory if it exists
|
||||||
@@ -680,7 +681,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
if not db_session:
|
if not db_session:
|
||||||
raise HTTPException(404, f"Session {sid} not found")
|
raise HTTPException(404, f"Session {sid} not found")
|
||||||
db_session.archived = False
|
db_session.archived = False
|
||||||
db_session.updated_at = datetime.utcnow()
|
db_session.updated_at = utcnow_naive()
|
||||||
db.commit()
|
db.commit()
|
||||||
# Reload into session manager so it appears in the active list
|
# Reload into session manager so it appears in the active list
|
||||||
try:
|
try:
|
||||||
@@ -890,7 +891,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||||
if db_session:
|
if db_session:
|
||||||
db_session.is_important = important
|
db_session.is_important = important
|
||||||
db_session.updated_at = datetime.utcnow()
|
db_session.updated_at = utcnow_naive()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Update in memory if it exists
|
# Update in memory if it exists
|
||||||
@@ -979,7 +980,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
metadata={
|
metadata={
|
||||||
"compacted": True,
|
"compacted": True,
|
||||||
"summarized_count": len(older),
|
"summarized_count": len(older),
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
"timestamp": utcnow_naive().isoformat(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
new_history = [summary_msg] + recent
|
new_history = [summary_msg] + recent
|
||||||
@@ -1256,7 +1257,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
db_session = db_session_q.first()
|
db_session = db_session_q.first()
|
||||||
if db_session:
|
if db_session:
|
||||||
db_session.folder = folder_name
|
db_session.folder = folder_name
|
||||||
db_session.updated_at = datetime.utcnow()
|
db_session.updated_at = utcnow_naive()
|
||||||
updated += 1
|
updated += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+115
-34
@@ -16,6 +16,11 @@ from pathlib import Path
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from core.platform_compat import IS_APPLE_SILICON, which_tool
|
from core.platform_compat import IS_APPLE_SILICON, which_tool
|
||||||
from core.middleware import INTERNAL_TOOL_USER
|
from core.middleware import INTERNAL_TOOL_USER
|
||||||
|
from src.host_docker_access import (
|
||||||
|
HOST_DOCKER_ACCESS_HINT,
|
||||||
|
host_docker_access_enabled as _host_docker_access_enabled,
|
||||||
|
running_in_container as _running_in_container,
|
||||||
|
)
|
||||||
from src.optional_deps import prepare_optional_dependency_import
|
from src.optional_deps import prepare_optional_dependency_import
|
||||||
|
|
||||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
||||||
@@ -103,32 +108,17 @@ logger = logging.getLogger(__name__)
|
|||||||
PTY_SUPPORTED = pty is not None and fcntl is not None and hasattr(os, "setsid")
|
PTY_SUPPORTED = pty is not None and fcntl is not None and hasattr(os, "setsid")
|
||||||
|
|
||||||
|
|
||||||
DOCKER_IN_CONTAINER_HINT = (
|
DOCKER_IN_CONTAINER_HINT = HOST_DOCKER_ACCESS_HINT
|
||||||
"Not available inside the Odysseus container by design. The image ships no "
|
|
||||||
"docker CLI and no host socket is mounted. Run Docker-backed launches on a "
|
|
||||||
"remote server, where docker is checked over SSH. Mounting /var/run/docker.sock "
|
|
||||||
"into the container would grant it host-root access, so only do that if you "
|
|
||||||
"accept that risk."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _running_in_container(dockerenv_path="/.dockerenv", cgroup_path="/proc/1/cgroup"):
|
|
||||||
if os.path.exists(dockerenv_path):
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
with open(cgroup_path, "r", encoding="utf-8") as fh:
|
|
||||||
contents = fh.read()
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
return any(token in contents for token in ("docker", "containerd", "kubepods"))
|
|
||||||
|
|
||||||
|
|
||||||
DockerRowStatus = namedtuple("DockerRowStatus", ["applicable", "install_hint"])
|
DockerRowStatus = namedtuple("DockerRowStatus", ["applicable", "install_hint"])
|
||||||
PackageUpdateStatus = namedtuple("PackageUpdateStatus", ["available", "note"])
|
PackageUpdateStatus = namedtuple("PackageUpdateStatus", ["available", "note"])
|
||||||
|
|
||||||
|
|
||||||
def _docker_row_status(*, on_remote, in_container, installed, default_hint):
|
def _docker_row_status(
|
||||||
local_docker_unavailable = not on_remote and in_container and not installed
|
*, on_remote, in_container, installed, default_hint, host_docker_access=False
|
||||||
|
):
|
||||||
|
local_docker_unavailable = not on_remote and in_container and not host_docker_access
|
||||||
if local_docker_unavailable:
|
if local_docker_unavailable:
|
||||||
return DockerRowStatus(applicable=False, install_hint=DOCKER_IN_CONTAINER_HINT)
|
return DockerRowStatus(applicable=False, install_hint=DOCKER_IN_CONTAINER_HINT)
|
||||||
return DockerRowStatus(applicable=True, install_hint=default_hint)
|
return DockerRowStatus(applicable=True, install_hint=default_hint)
|
||||||
@@ -174,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"))
|
return bool(binaries.get("llama-server") or dists.get("llama-cpp-python"))
|
||||||
if name == "sglang":
|
if name == "sglang":
|
||||||
return bool(dists.get("sglang") or modules.get("sglang", {}).get("real_module"))
|
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":
|
if name == "diffusers":
|
||||||
return bool(
|
return bool(
|
||||||
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
|
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
|
||||||
@@ -220,6 +212,10 @@ def _package_status_note(name: str, probe: dict) -> str:
|
|||||||
if _package_installed_from_probe(name, probe):
|
if _package_installed_from_probe(name, probe):
|
||||||
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}"
|
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}"
|
||||||
return "Diffusers serving needs both diffusers and torch."
|
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:
|
if name in dists:
|
||||||
return f"{name} {dists[name]}"
|
return f"{name} {dists[name]}"
|
||||||
return ""
|
return ""
|
||||||
@@ -317,12 +313,14 @@ dist_names={{
|
|||||||
'vllm':['vllm'],
|
'vllm':['vllm'],
|
||||||
'llama_cpp':['llama-cpp-python'],
|
'llama_cpp':['llama-cpp-python'],
|
||||||
'sglang':['sglang'],
|
'sglang':['sglang'],
|
||||||
|
'mlx_lm':['mlx-lm'],
|
||||||
'diffusers':['diffusers','torch'],
|
'diffusers':['diffusers','torch'],
|
||||||
'hf_transfer':['hf-transfer','hf_transfer'],
|
'hf_transfer':['hf-transfer','hf_transfer'],
|
||||||
}}
|
}}
|
||||||
bin_names={{
|
bin_names={{
|
||||||
'vllm':['vllm'],
|
'vllm':['vllm'],
|
||||||
'llama_cpp':['llama-server'],
|
'llama_cpp':['llama-server'],
|
||||||
|
'tmux':['tmux'],
|
||||||
}}
|
}}
|
||||||
|
|
||||||
def add_user_install_bins_to_path():
|
def add_user_install_bins_to_path():
|
||||||
@@ -335,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/bin'))
|
||||||
candidates.append(os.path.expanduser('~/llama.cpp/build-vulkan/bin'))
|
candidates.append(os.path.expanduser('~/llama.cpp/build-vulkan/bin'))
|
||||||
candidates.append(os.path.expanduser('~/.local/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 []
|
parts = os.environ.get('PATH', '').split(os.pathsep) if os.environ.get('PATH') else []
|
||||||
changed = False
|
changed = False
|
||||||
for path in reversed([p for p in candidates if p]):
|
for path in reversed([p for p in candidates if p]):
|
||||||
@@ -409,6 +409,47 @@ class ShellExecRequest(BaseModel):
|
|||||||
use_tmux: bool = False # run in tmux session (survives browser disconnect)
|
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):
|
async def _create_shell(command: str, **kwargs):
|
||||||
"""Spawn a shell subprocess for `command`.
|
"""Spawn a shell subprocess for `command`.
|
||||||
|
|
||||||
@@ -825,6 +866,10 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
if not cmd:
|
if not cmd:
|
||||||
return {"stdout": "", "stderr": "No command provided", "exit_code": 1}
|
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))
|
logger.info("User shell exec requested: length=%d", len(cmd))
|
||||||
result = await _exec_shell(
|
result = await _exec_shell(
|
||||||
cmd, timeout=req.timeout if req.timeout is not None else EXEC_TIMEOUT
|
cmd, timeout=req.timeout if req.timeout is not None else EXEC_TIMEOUT
|
||||||
@@ -1144,6 +1189,13 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"category": "LLM",
|
"category": "LLM",
|
||||||
"target": "remote",
|
"target": "remote",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "mlx_lm",
|
||||||
|
"pip": "mlx-lm",
|
||||||
|
"desc": "Serve MLX-format models on Apple Silicon Macs",
|
||||||
|
"category": "LLM",
|
||||||
|
"target": "remote",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "APFEL",
|
"name": "APFEL",
|
||||||
"pip": "",
|
"pip": "",
|
||||||
@@ -1159,7 +1211,7 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
{
|
{
|
||||||
"name": "diffusers",
|
"name": "diffusers",
|
||||||
"pip": "diffusers[torch]",
|
"pip": "diffusers[torch]",
|
||||||
"desc": "Image generation pipelines (SD, Flux) with PyTorch",
|
"desc": "Image generation/editing pipelines (SD, Flux) with PyTorch",
|
||||||
"category": "Image",
|
"category": "Image",
|
||||||
"target": "remote",
|
"target": "remote",
|
||||||
},
|
},
|
||||||
@@ -1289,9 +1341,9 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
for name in all_system_names:
|
for name in all_system_names:
|
||||||
qn = shlex.quote(name)
|
qn = shlex.quote(name)
|
||||||
checks.append(
|
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)
|
inner = " ; ".join(checks)
|
||||||
argv = _ssh_base_argv(host, ssh_port) + [inner]
|
argv = _ssh_base_argv(host, ssh_port) + [inner]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
@@ -1510,6 +1562,9 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
in_container=_running_in_container() if not on_remote else False,
|
in_container=_running_in_container() if not on_remote else False,
|
||||||
installed=pkg["installed"],
|
installed=pkg["installed"],
|
||||||
default_hint=pkg.get("install_hint"),
|
default_hint=pkg.get("install_hint"),
|
||||||
|
host_docker_access=(
|
||||||
|
_host_docker_access_enabled() if not on_remote else False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
pkg["applicable"] = status.applicable
|
pkg["applicable"] = status.applicable
|
||||||
pkg["install_hint"] = status.install_hint
|
pkg["install_hint"] = status.install_hint
|
||||||
@@ -1545,6 +1600,7 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"onnxruntime",
|
"onnxruntime",
|
||||||
"hdbscan",
|
"hdbscan",
|
||||||
"vllm",
|
"vllm",
|
||||||
|
"mlx-lm",
|
||||||
}
|
}
|
||||||
if pip_name not in known:
|
if pip_name not in known:
|
||||||
return {"ok": False, "error": f"Unknown package: {pip_name}"}
|
return {"ok": False, "error": f"Unknown package: {pip_name}"}
|
||||||
@@ -1591,6 +1647,19 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
elif n == "g++": out += ["gcc-c++"]
|
elif n == "g++": out += ["gcc-c++"]
|
||||||
else: out.append(n)
|
else: out.append(n)
|
||||||
return out
|
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):
|
def _brew(names):
|
||||||
return [n for n in names if n not in ("build-essential", "g++", "gcc", "make")]
|
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
|
# Build a single shell snippet that detects the package manager and
|
||||||
@@ -1599,6 +1668,8 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
apt_pkgs = " ".join(shlex.quote(p) for p in _apt(pkgs))
|
apt_pkgs = " ".join(shlex.quote(p) for p in _apt(pkgs))
|
||||||
pac_pkgs = " ".join(shlex.quote(p) for p in _pacman(pkgs))
|
pac_pkgs = " ".join(shlex.quote(p) for p in _pacman(pkgs))
|
||||||
dnf_pkgs = " ".join(shlex.quote(p) for p in _dnf(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))
|
brew_pkgs = " ".join(shlex.quote(p) for p in _brew(pkgs))
|
||||||
# Error messages go to stderr (>&2) so the route's error field
|
# Error messages go to stderr (>&2) so the route's error field
|
||||||
# gets populated. Without the redirect, `echo "ERROR…"` on stdout
|
# gets populated. Without the redirect, `echo "ERROR…"` on stdout
|
||||||
@@ -1606,18 +1677,28 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
# bare "HTTP 200" instead of surfacing the real reason.
|
# bare "HTTP 200" instead of surfacing the real reason.
|
||||||
script = (
|
script = (
|
||||||
'set -e; '
|
'set -e; '
|
||||||
'if ! sudo -n true 2>/dev/null; then '
|
'BREW="$(command -v brew 2>/dev/null || true)"; '
|
||||||
' 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 [ -z "$BREW" ] && [ -x /opt/homebrew/bin/brew ]; then BREW=/opt/homebrew/bin/brew; fi; '
|
||||||
'if command -v apt-get >/dev/null 2>&1; then '
|
'if [ -z "$BREW" ] && [ -x /usr/local/bin/brew ]; then BREW=/usr/local/bin/brew; fi; '
|
||||||
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}; '
|
'if [ -n "$BREW" ]; then '
|
||||||
'elif command -v pacman >/dev/null 2>&1; 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 $?; '
|
||||||
f' sudo -n pacman -Sy --needed --noconfirm {pac_pkgs}; '
|
'fi; '
|
||||||
'elif command -v dnf >/dev/null 2>&1; then '
|
'if [ "$(id -u)" = "0" ]; then SUDO=""; '
|
||||||
f' sudo -n dnf install -y {dnf_pkgs}; '
|
'elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then SUDO="sudo -n"; '
|
||||||
'elif command -v brew >/dev/null 2>&1; then '
|
|
||||||
f' brew install {brew_pkgs}; '
|
|
||||||
'else '
|
'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:
|
try:
|
||||||
if host:
|
if host:
|
||||||
|
|||||||
+42
-26
@@ -11,10 +11,14 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from core.database import SessionLocal, ScheduledTask, TaskRun
|
from core.database import SessionLocal, ScheduledTask, TaskRun
|
||||||
from core.middleware import INTERNAL_TOOL_USER
|
|
||||||
from core.constants import internal_api_base
|
from core.constants import internal_api_base
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
|
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 src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
|
||||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||||
|
|
||||||
@@ -417,28 +421,18 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
db.close()
|
db.close()
|
||||||
return {"ok": True, "opened": True, "enabled": bool(prefs.get("tasks_enabled")), "resumed": resumed}
|
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
|
# Non-admin users cannot create tasks with these action types via the
|
||||||
# API. See review CRIT-C.
|
# 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:
|
def _is_admin(user: str | None) -> bool:
|
||||||
if not user:
|
return owner_has_admin_task_privileges(user)
|
||||||
return False
|
|
||||||
# In-process tool-loopback marker — AuthMiddleware validated
|
def _require_admin_for_task_action(user: str | None, task_type: str | None, action: str | None) -> None:
|
||||||
# the internal token + loopback client before stamping this,
|
if is_admin_only_task_action(task_type, action) and not _is_admin(user):
|
||||||
# so treat as admin-equivalent.
|
raise HTTPException(403, f"Action '{action}' requires admin privileges")
|
||||||
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
|
|
||||||
|
|
||||||
def _validate_then_task_id(db, then_task_id: Optional[str], user: Optional[str], current_task_id: Optional[str] = None) -> Optional[str]:
|
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()
|
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
|
# Block shell-executing action types for non-admins. action_run_local
|
||||||
# uses subprocess.run(shell=True) and ssh_command / run_script run
|
# uses subprocess.run(shell=True) and ssh_command / run_script run
|
||||||
# arbitrary commands.
|
# arbitrary commands.
|
||||||
if req.task_type == "action" and req.action in _ADMIN_ONLY_ACTIONS and not _is_admin(user):
|
_require_admin_for_task_action(user, req.task_type, req.action)
|
||||||
raise HTTPException(403, f"Action '{req.action}' requires admin privileges")
|
|
||||||
if req.trigger_type == "schedule" and not req.schedule:
|
if req.trigger_type == "schedule" and not req.schedule:
|
||||||
raise HTTPException(400, "Schedule is required for schedule-triggered tasks")
|
raise HTTPException(400, "Schedule is required for schedule-triggered tasks")
|
||||||
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
|
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
|
||||||
@@ -594,6 +587,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
cache_tables = {
|
cache_tables = {
|
||||||
"summarize_emails": ("email_summaries",),
|
"summarize_emails": ("email_summaries",),
|
||||||
"draft_email_replies": ("email_ai_replies",),
|
"draft_email_replies": ("email_ai_replies",),
|
||||||
|
"email_auto_translate": ("email_translations",),
|
||||||
"extract_email_events": ("email_calendar_extractions",),
|
"extract_email_events": ("email_calendar_extractions",),
|
||||||
"learn_sender_signatures": ("sender_signatures",),
|
"learn_sender_signatures": ("sender_signatures",),
|
||||||
"check_email_urgency": ("email_tags", "email_urgency_alerts"),
|
"check_email_urgency": ("email_tags", "email_urgency_alerts"),
|
||||||
@@ -680,6 +674,10 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
if user and task.owner != user:
|
if user and task.owner != user:
|
||||||
raise HTTPException(403, "Access denied")
|
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:
|
if req.name is not None:
|
||||||
task.name = req.name
|
task.name = req.name
|
||||||
if req.prompt is not None:
|
if req.prompt is not None:
|
||||||
@@ -687,9 +685,6 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
if req.task_type is not None:
|
if req.task_type is not None:
|
||||||
task.task_type = req.task_type
|
task.task_type = req.task_type
|
||||||
if req.action is not None:
|
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
|
task.action = req.action
|
||||||
if req.output_target is not None:
|
if req.output_target is not None:
|
||||||
task.output_target = req.output_target
|
task.output_target = req.output_target
|
||||||
@@ -806,6 +801,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
if user and task.owner != user:
|
if user and task.owner != user:
|
||||||
raise HTTPException(403, "Access denied")
|
raise HTTPException(403, "Access denied")
|
||||||
|
_require_admin_for_task_action(user, task.task_type, task.action)
|
||||||
task.status = "active"
|
task.status = "active"
|
||||||
if (task.trigger_type or "schedule") == "schedule":
|
if (task.trigger_type or "schedule") == "schedule":
|
||||||
task.next_run = compute_next_run(
|
task.next_run = compute_next_run(
|
||||||
@@ -868,6 +864,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
if user and task.owner != user:
|
if user and task.owner != user:
|
||||||
raise HTTPException(403, "Access denied")
|
raise HTTPException(403, "Access denied")
|
||||||
|
_require_admin_for_task_action(user, task.task_type, task.action)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
started = await task_scheduler.run_task_now(task_id, force=force)
|
started = await task_scheduler.run_task_now(task_id, force=force)
|
||||||
@@ -893,10 +890,11 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
return {"ok": True, "message": "Task stopped"}
|
return {"ok": True, "message": "Task stopped"}
|
||||||
|
|
||||||
@router.get("/runs/recent")
|
@router.get("/runs/recent")
|
||||||
async def list_recent_runs(request: Request, limit: int = 50):
|
async def list_recent_runs(request: Request, limit: int = 50, max_result_chars: int = 6000):
|
||||||
"""Recent task runs across ALL tasks for this owner. Drives the Activity view."""
|
"""Recent task runs across ALL tasks for this owner. Drives the Activity view."""
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
limit = max(1, min(limit, 200))
|
limit = max(1, min(limit, 200))
|
||||||
|
max_result_chars = max(500, min(max_result_chars, 20000))
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
q = db.query(TaskRun, ScheduledTask).join(
|
q = db.query(TaskRun, ScheduledTask).join(
|
||||||
@@ -930,10 +928,20 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
deduped.append((r, t))
|
deduped.append((r, t))
|
||||||
if len(deduped) >= limit:
|
if len(deduped) >= limit:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
def _clip_run(r: TaskRun) -> dict:
|
||||||
|
d = _run_to_dict(r)
|
||||||
|
for key in ("result", "error"):
|
||||||
|
val = d.get(key)
|
||||||
|
if isinstance(val, str) and len(val) > max_result_chars:
|
||||||
|
d[key] = val[:max_result_chars].rstrip() + "\n\n[Activity preview truncated]"
|
||||||
|
return d
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"has_more": len(rows) > len(deduped),
|
||||||
"runs": [
|
"runs": [
|
||||||
{
|
{
|
||||||
**_run_to_dict(r),
|
**_clip_run(r),
|
||||||
"task_name": _display_task_name(t),
|
"task_name": _display_task_name(t),
|
||||||
"task_type": t.task_type or "llm",
|
"task_type": t.task_type or "llm",
|
||||||
"action": t.action,
|
"action": t.action,
|
||||||
@@ -1046,6 +1054,14 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
).first()
|
).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(404, "Not found")
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
started = await task_scheduler.run_task_now(task_id)
|
started = await task_scheduler.run_task_now(task_id)
|
||||||
|
|||||||
+54
-7
@@ -6,11 +6,11 @@ import asyncio
|
|||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Request, File, UploadFile, HTTPException
|
from fastapi import APIRouter, Request, File, UploadFile, HTTPException, Form
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
import logging
|
import logging
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from core.database import SessionLocal, GalleryImage
|
from core.database import SessionLocal, GalleryImage, Session as DbSession
|
||||||
from src.auth_helpers import effective_user
|
from src.auth_helpers import effective_user
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
from src.upload_handler import count_recent_uploads
|
from src.upload_handler import count_recent_uploads
|
||||||
@@ -56,7 +56,17 @@ def setup_upload_routes(upload_handler):
|
|||||||
|
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
def _promote_chat_image_to_gallery(meta: dict, owner: str | None) -> str | None:
|
def _valid_session_id_for_owner(db, session_id: str | None, owner: str | None) -> str | None:
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
sess = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||||
|
if not sess:
|
||||||
|
return None
|
||||||
|
if owner and sess.owner and sess.owner != owner:
|
||||||
|
return None
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
def _promote_chat_image_to_gallery(meta: dict, owner: str | None, session_id: str | None = None) -> str | None:
|
||||||
"""Make chat-uploaded images visible in Gallery without changing chat storage."""
|
"""Make chat-uploaded images visible in Gallery without changing chat storage."""
|
||||||
is_image_file = getattr(upload_handler, "is_image_file", None)
|
is_image_file = getattr(upload_handler, "is_image_file", None)
|
||||||
if not callable(is_image_file):
|
if not callable(is_image_file):
|
||||||
@@ -105,6 +115,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
prompt=meta.get("name") or "Chat upload",
|
prompt=meta.get("name") or "Chat upload",
|
||||||
model="chat-upload",
|
model="chat-upload",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
session_id=_valid_session_id_for_owner(db, session_id, owner),
|
||||||
file_hash=file_hash,
|
file_hash=file_hash,
|
||||||
width=meta.get("width"),
|
width=meta.get("width"),
|
||||||
height=meta.get("height"),
|
height=meta.get("height"),
|
||||||
@@ -120,8 +131,14 @@ def setup_upload_routes(upload_handler):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def api_upload(request: Request, files: List[UploadFile] = File(...)):
|
async def api_upload(
|
||||||
|
request: Request,
|
||||||
|
files: List[UploadFile] = File(...),
|
||||||
|
session_id: Optional[str] = Form(None),
|
||||||
|
):
|
||||||
"""Upload files with enhanced security and organization."""
|
"""Upload files with enhanced security and organization."""
|
||||||
|
if not isinstance(session_id, str):
|
||||||
|
session_id = None
|
||||||
if not files:
|
if not files:
|
||||||
raise HTTPException(400, "No files uploaded")
|
raise HTTPException(400, "No files uploaded")
|
||||||
|
|
||||||
@@ -148,7 +165,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
try:
|
try:
|
||||||
owner = effective_user(request)
|
owner = effective_user(request)
|
||||||
meta = upload_handler.save_upload(u, client_ip, owner=owner)
|
meta = upload_handler.save_upload(u, client_ip, owner=owner)
|
||||||
gallery_id = _promote_chat_image_to_gallery(meta, owner)
|
gallery_id = _promote_chat_image_to_gallery(meta, owner, session_id)
|
||||||
item = {
|
item = {
|
||||||
"id": meta["id"],
|
"id": meta["id"],
|
||||||
"name": meta["name"],
|
"name": meta["name"],
|
||||||
@@ -263,6 +280,32 @@ def setup_upload_routes(upload_handler):
|
|||||||
os.makedirs(cache_dir, exist_ok=True)
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
return os.path.join(cache_dir, file_id + ".txt")
|
return os.path.join(cache_dir, file_id + ".txt")
|
||||||
|
|
||||||
|
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
|
||||||
|
"""Copy upload OCR/vision text onto the promoted gallery image row."""
|
||||||
|
if not info:
|
||||||
|
return
|
||||||
|
file_hash = info.get("hash")
|
||||||
|
if not file_hash:
|
||||||
|
return
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
q = db.query(GalleryImage).filter(
|
||||||
|
GalleryImage.file_hash == file_hash,
|
||||||
|
GalleryImage.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
if owner:
|
||||||
|
q = q.filter(GalleryImage.owner == owner)
|
||||||
|
img = q.first()
|
||||||
|
if not img:
|
||||||
|
return
|
||||||
|
img.caption = (text or "").strip()
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.warning("Failed to sync OCR caption to gallery image: %s", e)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
@router.get("/{file_id}/vision")
|
@router.get("/{file_id}/vision")
|
||||||
async def get_vision_text(request: Request, file_id: str, force: int = 0):
|
async def get_vision_text(request: Request, file_id: str, force: int = 0):
|
||||||
"""Return the vision-model OCR/description for an uploaded image.
|
"""Return the vision-model OCR/description for an uploaded image.
|
||||||
@@ -289,7 +332,9 @@ def setup_upload_routes(upload_handler):
|
|||||||
if not force and os.path.exists(cache_path):
|
if not force and os.path.exists(cache_path):
|
||||||
try:
|
try:
|
||||||
with open(cache_path, encoding="utf-8") as f:
|
with open(cache_path, encoding="utf-8") as f:
|
||||||
return {"text": f.read(), "cached": True}
|
cached_text = f.read()
|
||||||
|
_sync_gallery_caption_for_upload(info, file_owner or current_user, cached_text)
|
||||||
|
return {"text": cached_text, "cached": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Vision cache read failed for {file_id}: {e}")
|
logger.warning(f"Vision cache read failed for {file_id}: {e}")
|
||||||
from src.document_processor import analyze_image_with_vl
|
from src.document_processor import analyze_image_with_vl
|
||||||
@@ -303,6 +348,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
f.write(text)
|
f.write(text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Vision cache write failed for {file_id}: {e}")
|
logger.warning(f"Vision cache write failed for {file_id}: {e}")
|
||||||
|
_sync_gallery_caption_for_upload(info, file_owner or current_user, text)
|
||||||
return {"text": text, "cached": False}
|
return {"text": text, "cached": False}
|
||||||
|
|
||||||
@router.put("/{file_id}/vision")
|
@router.put("/{file_id}/vision")
|
||||||
@@ -333,6 +379,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
raise HTTPException(400, "text must be a string")
|
raise HTTPException(400, "text must be a string")
|
||||||
with open(_vision_cache_path(file_id), "w", encoding="utf-8") as f:
|
with open(_vision_cache_path(file_id), "w", encoding="utf-8") as f:
|
||||||
f.write(text)
|
f.write(text)
|
||||||
|
_sync_gallery_caption_for_upload(info, file_owner or current_user, text)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
async def periodic_rate_limit_cleanup():
|
async def periodic_rate_limit_cleanup():
|
||||||
|
|||||||
@@ -5113,8 +5113,8 @@
|
|||||||
{
|
{
|
||||||
"name": "deepseek-ai/DeepSeek-V4-Flash",
|
"name": "deepseek-ai/DeepSeek-V4-Flash",
|
||||||
"provider": "deepseek-ai",
|
"provider": "deepseek-ai",
|
||||||
"parameter_count": "284B",
|
"parameter_count": "158.1B",
|
||||||
"parameters_raw": 284000000000,
|
"parameters_raw": 158069433298,
|
||||||
"active_parameters": 13000000000,
|
"active_parameters": 13000000000,
|
||||||
"is_moe": true,
|
"is_moe": true,
|
||||||
"min_ram_gb": 200.0,
|
"min_ram_gb": 200.0,
|
||||||
@@ -5130,15 +5130,40 @@
|
|||||||
],
|
],
|
||||||
"pipeline_tag": "text-generation",
|
"pipeline_tag": "text-generation",
|
||||||
"architecture": "deepseek_v4_moe",
|
"architecture": "deepseek_v4_moe",
|
||||||
"hf_downloads": 3542202,
|
"hf_downloads": 1882337,
|
||||||
"hf_likes": 0,
|
"hf_likes": 1651,
|
||||||
"release_date": "2026-05-15"
|
"release_date": "2026-06-22"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deepseek-ai/DeepSeek-V4-Flash-DSpark",
|
||||||
|
"provider": "deepseek-ai",
|
||||||
|
"parameter_count": "165.3B",
|
||||||
|
"parameters_raw": 165265454782,
|
||||||
|
"active_parameters": 13000000000,
|
||||||
|
"is_moe": true,
|
||||||
|
"active_experts": 6,
|
||||||
|
"min_ram_gb": 170.0,
|
||||||
|
"recommended_ram_gb": 250.0,
|
||||||
|
"min_vram_gb": 165.0,
|
||||||
|
"quantization": "FP8-Mixed",
|
||||||
|
"context_length": 1000000,
|
||||||
|
"use_case": "General-purpose reasoning, long-context",
|
||||||
|
"capabilities": [
|
||||||
|
"long_context",
|
||||||
|
"reasoning",
|
||||||
|
"moe"
|
||||||
|
],
|
||||||
|
"pipeline_tag": "text-generation",
|
||||||
|
"architecture": "deepseek_v4_moe",
|
||||||
|
"hf_downloads": 4446,
|
||||||
|
"hf_likes": 107,
|
||||||
|
"release_date": "2026-06-27"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deepseek-ai/DeepSeek-V4-Flash-Base",
|
"name": "deepseek-ai/DeepSeek-V4-Flash-Base",
|
||||||
"provider": "deepseek-ai",
|
"provider": "deepseek-ai",
|
||||||
"parameter_count": "284B",
|
"parameter_count": "292.0B",
|
||||||
"parameters_raw": 284000000000,
|
"parameters_raw": 292021347282,
|
||||||
"active_parameters": 13000000000,
|
"active_parameters": 13000000000,
|
||||||
"is_moe": true,
|
"is_moe": true,
|
||||||
"min_ram_gb": 290.0,
|
"min_ram_gb": 290.0,
|
||||||
@@ -5153,15 +5178,15 @@
|
|||||||
],
|
],
|
||||||
"pipeline_tag": "text-generation",
|
"pipeline_tag": "text-generation",
|
||||||
"architecture": "deepseek_v4_moe",
|
"architecture": "deepseek_v4_moe",
|
||||||
"hf_downloads": 0,
|
"hf_downloads": 76030,
|
||||||
"hf_likes": 0,
|
"hf_likes": 256,
|
||||||
"release_date": "2026-05-15"
|
"release_date": "2026-04-27"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deepseek-ai/DeepSeek-V4-Pro",
|
"name": "deepseek-ai/DeepSeek-V4-Pro",
|
||||||
"provider": "deepseek-ai",
|
"provider": "deepseek-ai",
|
||||||
"parameter_count": "1.6T",
|
"parameter_count": "861.6B",
|
||||||
"parameters_raw": 1600000000000,
|
"parameters_raw": 861608274846,
|
||||||
"active_parameters": 49000000000,
|
"active_parameters": 49000000000,
|
||||||
"is_moe": true,
|
"is_moe": true,
|
||||||
"min_ram_gb": 1100.0,
|
"min_ram_gb": 1100.0,
|
||||||
@@ -5177,15 +5202,40 @@
|
|||||||
],
|
],
|
||||||
"pipeline_tag": "text-generation",
|
"pipeline_tag": "text-generation",
|
||||||
"architecture": "deepseek_v4_moe",
|
"architecture": "deepseek_v4_moe",
|
||||||
"hf_downloads": 0,
|
"hf_downloads": 1154610,
|
||||||
"hf_likes": 0,
|
"hf_likes": 5118,
|
||||||
"release_date": "2026-05-15"
|
"release_date": "2026-06-22"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deepseek-ai/DeepSeek-V4-Pro-DSpark",
|
||||||
|
"provider": "deepseek-ai",
|
||||||
|
"parameter_count": "889.5B",
|
||||||
|
"parameters_raw": 889484881098,
|
||||||
|
"active_parameters": 49000000000,
|
||||||
|
"is_moe": true,
|
||||||
|
"active_experts": 6,
|
||||||
|
"min_ram_gb": 900.0,
|
||||||
|
"recommended_ram_gb": 1250.0,
|
||||||
|
"min_vram_gb": 890.0,
|
||||||
|
"quantization": "FP8-Mixed",
|
||||||
|
"context_length": 1000000,
|
||||||
|
"use_case": "Flagship reasoning, long-context",
|
||||||
|
"capabilities": [
|
||||||
|
"long_context",
|
||||||
|
"reasoning",
|
||||||
|
"moe"
|
||||||
|
],
|
||||||
|
"pipeline_tag": "text-generation",
|
||||||
|
"architecture": "deepseek_v4_moe",
|
||||||
|
"hf_downloads": 6939,
|
||||||
|
"hf_likes": 241,
|
||||||
|
"release_date": "2026-06-27"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deepseek-ai/DeepSeek-V4-Pro-Base",
|
"name": "deepseek-ai/DeepSeek-V4-Pro-Base",
|
||||||
"provider": "deepseek-ai",
|
"provider": "deepseek-ai",
|
||||||
"parameter_count": "1.6T",
|
"parameter_count": "1.6T",
|
||||||
"parameters_raw": 1600000000000,
|
"parameters_raw": 1600790440862,
|
||||||
"active_parameters": 49000000000,
|
"active_parameters": 49000000000,
|
||||||
"is_moe": true,
|
"is_moe": true,
|
||||||
"min_ram_gb": 1700.0,
|
"min_ram_gb": 1700.0,
|
||||||
@@ -5200,9 +5250,9 @@
|
|||||||
],
|
],
|
||||||
"pipeline_tag": "text-generation",
|
"pipeline_tag": "text-generation",
|
||||||
"architecture": "deepseek_v4_moe",
|
"architecture": "deepseek_v4_moe",
|
||||||
"hf_downloads": 0,
|
"hf_downloads": 25387,
|
||||||
"hf_likes": 0,
|
"hf_likes": 305,
|
||||||
"release_date": "2026-05-15"
|
"release_date": "2026-04-27"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deepseek-ai/deepseek-coder-6.7b-base",
|
"name": "deepseek-ai/deepseek-coder-6.7b-base",
|
||||||
@@ -13308,6 +13358,106 @@
|
|||||||
"_discovered": true,
|
"_discovered": true,
|
||||||
"gguf_sources": []
|
"gguf_sources": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "zai-org/GLM-5.2",
|
||||||
|
"provider": "zai-org",
|
||||||
|
"parameter_count": "753.3B",
|
||||||
|
"parameters_raw": 753329940480,
|
||||||
|
"min_ram_gb": 1510.0,
|
||||||
|
"recommended_ram_gb": 1800.0,
|
||||||
|
"min_vram_gb": 1510.0,
|
||||||
|
"quantization": "BF16",
|
||||||
|
"context_length": 1048576,
|
||||||
|
"use_case": "General purpose reasoning, coding, long-context",
|
||||||
|
"capabilities": [
|
||||||
|
"long_context",
|
||||||
|
"reasoning",
|
||||||
|
"coding",
|
||||||
|
"moe"
|
||||||
|
],
|
||||||
|
"pipeline_tag": "text-generation",
|
||||||
|
"architecture": "glm_moe_dsa",
|
||||||
|
"hf_downloads": 142547,
|
||||||
|
"hf_likes": 2996,
|
||||||
|
"release_date": "2026-06-23",
|
||||||
|
"is_moe": true,
|
||||||
|
"active_experts": 8,
|
||||||
|
"gguf_sources": [
|
||||||
|
{
|
||||||
|
"repo": "unsloth/GLM-5.2-GGUF",
|
||||||
|
"provider": "unsloth",
|
||||||
|
"file": "UD-Q4_K_M/*.gguf",
|
||||||
|
"quant": "Q4_K_M"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "zai-org/GLM-5.2-FP8",
|
||||||
|
"provider": "zai-org",
|
||||||
|
"parameter_count": "753.4B",
|
||||||
|
"parameters_raw": 753375793584,
|
||||||
|
"min_ram_gb": 760.0,
|
||||||
|
"recommended_ram_gb": 900.0,
|
||||||
|
"min_vram_gb": 760.0,
|
||||||
|
"quantization": "FP8",
|
||||||
|
"context_length": 1048576,
|
||||||
|
"use_case": "General purpose reasoning, coding, long-context",
|
||||||
|
"capabilities": [
|
||||||
|
"long_context",
|
||||||
|
"reasoning",
|
||||||
|
"coding",
|
||||||
|
"moe"
|
||||||
|
],
|
||||||
|
"pipeline_tag": "text-generation",
|
||||||
|
"architecture": "glm_moe_dsa",
|
||||||
|
"hf_downloads": 884226,
|
||||||
|
"hf_likes": 182,
|
||||||
|
"release_date": "2026-06-23",
|
||||||
|
"is_moe": true,
|
||||||
|
"active_experts": 8,
|
||||||
|
"gguf_sources": [
|
||||||
|
{
|
||||||
|
"repo": "unsloth/GLM-5.2-GGUF",
|
||||||
|
"provider": "unsloth",
|
||||||
|
"file": "UD-Q4_K_M/*.gguf",
|
||||||
|
"quant": "Q4_K_M"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unsloth/GLM-5.2-GGUF",
|
||||||
|
"provider": "unsloth",
|
||||||
|
"parameter_count": "753.9B",
|
||||||
|
"parameters_raw": 753864139008,
|
||||||
|
"min_ram_gb": 452.0,
|
||||||
|
"recommended_ram_gb": 620.0,
|
||||||
|
"min_vram_gb": 452.0,
|
||||||
|
"quantization": "Q4_K_M",
|
||||||
|
"context_length": 1048576,
|
||||||
|
"use_case": "General purpose reasoning, coding, long-context (GGUF)",
|
||||||
|
"capabilities": [
|
||||||
|
"long_context",
|
||||||
|
"reasoning",
|
||||||
|
"coding",
|
||||||
|
"moe"
|
||||||
|
],
|
||||||
|
"pipeline_tag": "text-generation",
|
||||||
|
"architecture": "glm-dsa",
|
||||||
|
"hf_downloads": 180394,
|
||||||
|
"hf_likes": 474,
|
||||||
|
"release_date": "2026-06-23",
|
||||||
|
"is_moe": true,
|
||||||
|
"active_experts": 8,
|
||||||
|
"is_gguf": true,
|
||||||
|
"gguf_sources": [
|
||||||
|
{
|
||||||
|
"repo": "unsloth/GLM-5.2-GGUF",
|
||||||
|
"provider": "unsloth",
|
||||||
|
"file": "UD-Q4_K_M/*.gguf",
|
||||||
|
"quant": "Q4_K_M"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "cyankiwi/Qwen3.5-35B-A3B-AWQ-4bit",
|
"name": "cyankiwi/Qwen3.5-35B-A3B-AWQ-4bit",
|
||||||
"provider": "cyankiwi",
|
"provider": "cyankiwi",
|
||||||
@@ -18955,6 +19105,54 @@
|
|||||||
"active_experts": 8,
|
"active_experts": 8,
|
||||||
"active_parameters": 13600000000
|
"active_parameters": 13600000000
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "MiniMaxAI/MiniMax-M3",
|
||||||
|
"provider": "MiniMaxAI",
|
||||||
|
"parameter_count": "427.0B",
|
||||||
|
"parameters_raw": 427040140160,
|
||||||
|
"min_ram_gb": 855.0,
|
||||||
|
"recommended_ram_gb": 1025.0,
|
||||||
|
"min_vram_gb": 855.0,
|
||||||
|
"quantization": "BF16",
|
||||||
|
"context_length": 1000000,
|
||||||
|
"use_case": "Vision, chat, coding, agentic tool use",
|
||||||
|
"capabilities": [
|
||||||
|
"vision",
|
||||||
|
"tool_use",
|
||||||
|
"coding",
|
||||||
|
"moe"
|
||||||
|
],
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"architecture": "minimax_m3_vl",
|
||||||
|
"hf_downloads": 192311,
|
||||||
|
"hf_likes": 1267,
|
||||||
|
"release_date": "2026-06-23",
|
||||||
|
"is_moe": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MiniMaxAI/MiniMax-M3-MXFP8",
|
||||||
|
"provider": "MiniMaxAI",
|
||||||
|
"parameter_count": "440.3B",
|
||||||
|
"parameters_raw": 440279845760,
|
||||||
|
"min_ram_gb": 445.0,
|
||||||
|
"recommended_ram_gb": 560.0,
|
||||||
|
"min_vram_gb": 445.0,
|
||||||
|
"quantization": "MXFP8",
|
||||||
|
"context_length": 1000000,
|
||||||
|
"use_case": "Vision, chat, coding, agentic tool use",
|
||||||
|
"capabilities": [
|
||||||
|
"vision",
|
||||||
|
"tool_use",
|
||||||
|
"coding",
|
||||||
|
"moe"
|
||||||
|
],
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"architecture": "minimax_m3_vl",
|
||||||
|
"hf_downloads": 572278,
|
||||||
|
"hf_likes": 43,
|
||||||
|
"release_date": "2026-06-15",
|
||||||
|
"is_moe": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "bullerwins/MiniMax-M2.7-REAP-172B-fp8",
|
"name": "bullerwins/MiniMax-M2.7-REAP-172B-fp8",
|
||||||
"provider": "bullerwins",
|
"provider": "bullerwins",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+111
-13
@@ -168,6 +168,19 @@ def _canonical_cpu_backend(system):
|
|||||||
return "cpu_x86"
|
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):
|
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).
|
"""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
|
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):
|
def _context_score(ctx, use_case):
|
||||||
target = CONTEXT_TARGET.get(use_case, 4096)
|
target = CONTEXT_TARGET.get(use_case, 4096)
|
||||||
if ctx >= target:
|
if ctx >= target:
|
||||||
@@ -516,22 +545,43 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None, targe
|
|||||||
run_mode, quant, fit_ctx, required_gb = result
|
run_mode, quant, fit_ctx, required_gb = result
|
||||||
|
|
||||||
# Determine fit level
|
# 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:
|
if required_gb > budget:
|
||||||
return None
|
return None
|
||||||
if run_mode == "gpu":
|
if run_mode == "gpu":
|
||||||
rec = model.get("recommended_ram_gb") or required_gb
|
if unified_memory:
|
||||||
if rec <= gpu_vram:
|
fit_level = _fit_level_for_budget(required_gb, budget)
|
||||||
|
else:
|
||||||
|
# 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"
|
fit_level = "perfect"
|
||||||
elif gpu_vram >= required_gb * 1.2:
|
elif gpu_vram >= required_gb * 1.2:
|
||||||
fit_level = "good"
|
fit_level = "good"
|
||||||
else:
|
else:
|
||||||
fit_level = "marginal"
|
fit_level = "marginal"
|
||||||
elif run_mode == "cpu_offload":
|
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:
|
else:
|
||||||
|
fit_level = _fit_level_for_budget(required_gb, budget)
|
||||||
|
if fit_level == "too_tight":
|
||||||
fit_level = "marginal"
|
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
|
# 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.
|
# model). When offloading, anything beyond the GPU's VRAM lives in system RAM.
|
||||||
offload_frac = 0.0
|
offload_frac = 0.0
|
||||||
@@ -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):
|
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.
|
"""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:
|
for m in models:
|
||||||
native_q = _native_quant(m)
|
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
|
# MLX is Apple Silicon-only. It should never appear on CUDA/ROCm/CPU,
|
||||||
# commands for. Hide it on every backend, including Metal.
|
# but it is first-class on Metal where mlx_lm.server can serve it.
|
||||||
if native_q.startswith("mlx-") or "mlx" in (m.get("name") or "").lower():
|
if is_mlx and not apple_silicon:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ROCm support for vLLM/SGLang quantized safetensors is too brittle to
|
# 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,
|
# Windows is the same: Odysseus only supports llama.cpp on Windows,
|
||||||
# which requires GGUF. vLLM/SGLang are explicitly blocked, so AWQ/GPTQ
|
# which requires GGUF. vLLM/SGLang are explicitly blocked, so AWQ/GPTQ
|
||||||
# models without a GGUF source are unservable there.
|
# 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
|
continue
|
||||||
|
|
||||||
# Format filter: AWQ tab -> only AWQ models, FP4 tab -> FP4-family models, etc.
|
# 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:
|
if quant in ("INT4", "INT8", "W4A16", "W8A8", "W8A16") and native_q != quant:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if search:
|
if search and not _matches_search(m, search):
|
||||||
name = m.get("name", "").lower()
|
|
||||||
provider = m.get("provider", "").lower()
|
|
||||||
if search.lower() not in name and search.lower() not in provider:
|
|
||||||
continue
|
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:
|
if result is None:
|
||||||
continue
|
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,
|
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
|
||||||
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
|
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
|
||||||
"QAT-INT4": 0.50, "QAT-INT8": 1.0,
|
"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-
|
# DeepSeek-V4-style mixed: MoE experts in FP4 (bulk), attention + non-
|
||||||
# expert dense in FP8, embeddings/LM head in BF16. By weight count the
|
# expert dense in FP8, embeddings/LM head in BF16. By weight count the
|
||||||
# experts dominate so the effective BPP sits closer to FP4 than FP8.
|
# 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,
|
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
|
||||||
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
|
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
|
||||||
"QAT-INT4": 1.15, "QAT-INT8": 0.85,
|
"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
|
"FP4-MoE-Mixed": 1.10, # slightly slower than pure FP4 because of mixed-dtype dispatch
|
||||||
"FP8-Mixed": 0.85,
|
"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
|
# 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.
|
# (Google reports near-bf16 quality). Penalize it lightly, not like Q4_K_M.
|
||||||
"QAT-INT4": -1.0, "QAT-INT8": 0.0,
|
"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),
|
# DeepSeek-V4 mixed: only MoE experts at FP4 (the rest is FP8/BF16),
|
||||||
# so the realized quality is much closer to FP8 than to pure FP4 —
|
# so the realized quality is much closer to FP8 than to pure FP4 —
|
||||||
# the activation-sensitive layers stay high-precision. ~0 penalty.
|
# 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,
|
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
|
||||||
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
|
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
|
||||||
"QAT-INT4": 0.5, "QAT-INT8": 1.0,
|
"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,
|
"FP4-MoE-Mixed": 0.55,
|
||||||
"FP8-Mixed": 1.0,
|
"FP8-Mixed": 1.0,
|
||||||
}
|
}
|
||||||
@@ -87,8 +87,11 @@ PREQUANTIZED_PREFIXES = (
|
|||||||
|
|
||||||
def infer_quantization_from_name(name):
|
def infer_quantization_from_name(name):
|
||||||
n = (name or "").lower()
|
n = (name or "").lower()
|
||||||
|
model_name = n.rsplit("/", 1)[-1]
|
||||||
if "nvfp4" in n:
|
if "nvfp4" in n:
|
||||||
return "NVFP4"
|
return "NVFP4"
|
||||||
|
if re.search(r"(^|[-_/])bf16($|[-_/])", model_name):
|
||||||
|
return "BF16"
|
||||||
if "mxfp4" in n:
|
if "mxfp4" in n:
|
||||||
return "MXFP4"
|
return "MXFP4"
|
||||||
if re.search(r"(^|[-_/])nf4($|[-_/])", n):
|
if re.search(r"(^|[-_/])nf4($|[-_/])", n):
|
||||||
@@ -106,8 +109,12 @@ def infer_quantization_from_name(name):
|
|||||||
return "AWQ-8bit" if is8 else "AWQ-4bit"
|
return "AWQ-8bit" if is8 else "AWQ-4bit"
|
||||||
if "gptq" in n:
|
if "gptq" in n:
|
||||||
return "GPTQ-Int8" if is8 else "GPTQ-Int4"
|
return "GPTQ-Int8" if is8 else "GPTQ-Int4"
|
||||||
if "mlx" in n:
|
if n.startswith("mlx-community/") or "mlx" in model_name:
|
||||||
if "6bit" in n:
|
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-6bit"
|
||||||
return "mlx-8bit" if is8 else "mlx-4bit"
|
return "mlx-8bit" if is8 else "mlx-4bit"
|
||||||
if "fp8" in n:
|
if "fp8" in n:
|
||||||
@@ -260,15 +267,75 @@ def infer_use_case(model):
|
|||||||
|
|
||||||
_models_cache = None
|
_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():
|
def get_models():
|
||||||
global _models_cache
|
global _models_cache
|
||||||
if _models_cache is None:
|
if _models_cache is None:
|
||||||
data_path = os.path.join(os.path.dirname(__file__), "data", "hf_models.json")
|
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:
|
try:
|
||||||
with open(data_path, encoding="utf-8") as f:
|
from services.hwfit.hf_discovery import (
|
||||||
_models_cache = [_normalize_model_entry(m) for m in json.load(f)]
|
load_cached_hf_collection_models,
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
load_cached_mlx_community_models,
|
||||||
_models_cache = []
|
)
|
||||||
|
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
|
return _models_cache
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+170
-26
@@ -8,11 +8,13 @@ import os
|
|||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import List
|
from typing import Iterable, List, cast
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import httpcore
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT
|
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT
|
||||||
@@ -91,6 +93,148 @@ def _public_http_url(url: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_public_ips(url: str) -> list[ipaddress._BaseAddress]:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||||
|
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||||
|
host = (parsed.hostname or "").strip().lower()
|
||||||
|
if host in ("localhost", "metadata", "metadata.google.internal"):
|
||||||
|
raise httpx.RequestError(f"Blocked non-public hostname: {host}")
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
if _is_private_address(ip):
|
||||||
|
raise httpx.RequestError(f"Blocked non-public IP literal: {host}")
|
||||||
|
return [ip]
|
||||||
|
except httpx.RequestError:
|
||||||
|
raise
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
addrs = _resolve_hostname_ips(host)
|
||||||
|
if not addrs or any(_is_private_address(a) for a in addrs):
|
||||||
|
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||||
|
return addrs
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedBackend(httpcore.NetworkBackend):
|
||||||
|
"""Network backend that connects to a pre-resolved IP.
|
||||||
|
|
||||||
|
httpcore derives the TLS SNI and the ``Host`` header from the URL's
|
||||||
|
origin, not from the host argument passed to ``connect_tcp``. So
|
||||||
|
routing the TCP connect to a resolved IP while leaving the URL
|
||||||
|
untouched keeps SNI / vhost behaviour correct and closes the
|
||||||
|
DNS-rebinding TOCTOU between the SSRF check and the connect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ip: ipaddress._BaseAddress):
|
||||||
|
self._ip = str(ip)
|
||||||
|
self._real = httpcore.SyncBackend()
|
||||||
|
|
||||||
|
def connect_tcp(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
timeout: float | None = None,
|
||||||
|
local_address: str | None = None,
|
||||||
|
socket_options=None,
|
||||||
|
):
|
||||||
|
return self._real.connect_tcp(
|
||||||
|
self._ip, port, timeout, local_address, socket_options
|
||||||
|
)
|
||||||
|
|
||||||
|
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||||
|
return self._real.connect_unix_socket(path, timeout, socket_options)
|
||||||
|
|
||||||
|
def sleep(self, seconds: float) -> None:
|
||||||
|
return self._real.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
# Map httpcore exception classes to their httpx equivalents. Built
|
||||||
|
# once at import time from the public exception classes; avoids any
|
||||||
|
# import of httpx's private transport machinery. httpcore's
|
||||||
|
# ``ConnectionNotAvailable`` is a pool-internal signal (the pool will
|
||||||
|
# close and retry on its own) — we never expect to see it surface to
|
||||||
|
# a transport caller, so it has no httpx counterpart here.
|
||||||
|
_HTTPCORE_TO_HTTPX_EXC = {
|
||||||
|
httpcore.ConnectError: httpx.ConnectError,
|
||||||
|
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||||
|
httpcore.LocalProtocolError: httpx.LocalProtocolError,
|
||||||
|
httpcore.NetworkError: httpx.NetworkError,
|
||||||
|
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||||
|
httpcore.ProtocolError: httpx.ProtocolError,
|
||||||
|
httpcore.ProxyError: httpx.ProxyError,
|
||||||
|
httpcore.ReadError: httpx.ReadError,
|
||||||
|
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||||
|
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||||
|
httpcore.TimeoutException: httpx.TimeoutException,
|
||||||
|
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
|
||||||
|
httpcore.WriteError: httpx.WriteError,
|
||||||
|
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedTransport(httpx.BaseTransport):
|
||||||
|
"""Transport that pins every TCP connect to a pre-resolved IP.
|
||||||
|
|
||||||
|
Uses only the public ``httpcore`` and ``httpx`` APIs — no
|
||||||
|
subclassing of ``httpx.HTTPTransport``, no reads of private
|
||||||
|
``httpcore.ConnectionPool`` attributes, no imports from
|
||||||
|
``httpx private transport internals``. The URL is passed through unchanged so SNI
|
||||||
|
/ vhost work as if httpx had been given the hostname directly;
|
||||||
|
only the TCP destination is pinned, closing the DNS-rebinding
|
||||||
|
TOCTOU between the SSRF check and the connect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ip: ipaddress._BaseAddress, *, http2: bool = False):
|
||||||
|
self._pool = httpcore.ConnectionPool(
|
||||||
|
ssl_context=ssl.create_default_context(),
|
||||||
|
http1=True,
|
||||||
|
http2=http2,
|
||||||
|
network_backend=_PinnedBackend(ip),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self._pool.__enter__()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type=None, exc_value=None, traceback=None) -> None:
|
||||||
|
self._pool.__exit__(exc_type, exc_value, traceback)
|
||||||
|
|
||||||
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
httpcore_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:
|
||||||
|
httpcore_resp = self._pool.handle_request(httpcore_req)
|
||||||
|
# Eager materialisation matches the original
|
||||||
|
# ``response.text`` usage in fetch_webpage_content. The
|
||||||
|
# sync pool's stream is a plain Iterable[bytes] despite
|
||||||
|
# the httpcore type hint unioning the async variant.
|
||||||
|
content = b"".join(cast(Iterable[bytes], httpcore_resp.stream))
|
||||||
|
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=httpcore_resp.status,
|
||||||
|
headers=httpcore_resp.headers,
|
||||||
|
content=content,
|
||||||
|
extensions=httpcore_resp.extensions,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._pool.close()
|
||||||
|
|
||||||
class BodyTooLargeError(Exception):
|
class BodyTooLargeError(Exception):
|
||||||
"""The server declared a body larger than the hard fetch ceiling."""
|
"""The server declared a body larger than the hard fetch ceiling."""
|
||||||
|
|
||||||
@@ -141,28 +285,32 @@ class _CappedFetch:
|
|||||||
|
|
||||||
def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int = 5,
|
def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int = 5,
|
||||||
max_bytes: int = None) -> "_CappedFetch":
|
max_bytes: int = None) -> "_CappedFetch":
|
||||||
"""Capped streaming GET with SSRF-guarded manual redirects.
|
"""Capped streaming GET with SSRF-guarded, DNS-pinned manual redirects.
|
||||||
|
|
||||||
The body is streamed and buffering stops at ``max_bytes`` (default: the
|
Each hop is resolved once, validated as public, and then the actual TCP
|
||||||
soft cap), so an oversized resource cannot be pulled into memory or the
|
connection is pinned to that resolved IP. The request URL is left unchanged
|
||||||
content cache in full. When Content-Length already declares a body over
|
so Host and TLS SNI keep the original hostname.
|
||||||
the hard ceiling, the fetch is refused before any body bytes are read.
|
|
||||||
"""
|
"""
|
||||||
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
|
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
|
||||||
current = url
|
current = url
|
||||||
for _ in range(max_redirects + 1):
|
for _ in range(max_redirects + 1):
|
||||||
if not _public_http_url(current):
|
ips = _resolve_public_ips(current)
|
||||||
raise httpx.RequestError("Blocked private/internal URL", request=httpx.Request("GET", current))
|
|
||||||
# Force identity transfer-encoding. With gzip/deflate the wire bytes
|
# Force identity transfer-encoding. With gzip/deflate the wire bytes
|
||||||
# (and Content-Length) can be a small fraction of the decoded body, so
|
# and Content-Length can be a small fraction of the decoded body, so a
|
||||||
# a tiny compressed response could pass the hard-cap preflight and then
|
# tiny compressed response could pass the hard-cap preflight and then
|
||||||
# expand past the ceiling in a single decoded chunk before the streamed
|
# expand past the ceiling in one decoded chunk before the streamed cap
|
||||||
# cap below can slice it. Identity makes Content-Length the true body
|
# below can slice it.
|
||||||
# size and keeps each streamed chunk bounded by the network read.
|
|
||||||
req_headers = dict(headers or {})
|
req_headers = dict(headers or {})
|
||||||
req_headers["Accept-Encoding"] = "identity"
|
req_headers["Accept-Encoding"] = "identity"
|
||||||
with httpx.stream("GET", current, headers=req_headers, timeout=timeout,
|
|
||||||
follow_redirects=False) as response:
|
with httpx.Client(
|
||||||
|
headers=req_headers,
|
||||||
|
timeout=timeout,
|
||||||
|
follow_redirects=False,
|
||||||
|
transport=_PinnedTransport(ips[0]),
|
||||||
|
) as client:
|
||||||
|
with client.stream("GET", current) as response:
|
||||||
if response.status_code in (301, 302, 303, 307, 308):
|
if response.status_code in (301, 302, 303, 307, 308):
|
||||||
location = response.headers.get("location")
|
location = response.headers.get("location")
|
||||||
if not location:
|
if not location:
|
||||||
@@ -172,11 +320,10 @@ def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int =
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# A server can ignore the identity request and still return a
|
# A server can ignore the identity request and still return a
|
||||||
# compressed body; httpx.iter_bytes would then decode it, and a tiny
|
# compressed body; httpx.iter_bytes would then decode it, and a
|
||||||
# gzip can balloon into one decoded chunk far past the cap before we
|
# tiny gzip can balloon into one decoded chunk far past the cap.
|
||||||
# slice. Refuse a compressed Content-Encoding so the streamed cap
|
# Refuse compressed Content-Encoding so the streamed cap stays
|
||||||
# stays a real memory bound (Content-Length is the compressed wire
|
# a real memory bound.
|
||||||
# length here, so the preflight and size metadata are unreliable too).
|
|
||||||
enc = (response.headers.get("content-encoding") or "").strip().lower()
|
enc = (response.headers.get("content-encoding") or "").strip().lower()
|
||||||
if enc and enc != "identity":
|
if enc and enc != "identity":
|
||||||
raise httpx.RequestError(
|
raise httpx.RequestError(
|
||||||
@@ -189,18 +336,13 @@ def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int =
|
|||||||
raw_len = response.headers.get("content-length")
|
raw_len = response.headers.get("content-length")
|
||||||
if raw_len and raw_len.isdigit():
|
if raw_len and raw_len.isdigit():
|
||||||
declared = int(raw_len)
|
declared = int(raw_len)
|
||||||
# Refuse before buffering anything when the server already tells
|
|
||||||
# us the body exceeds the absolute ceiling (Content-Length is wire
|
|
||||||
# bytes; the decompressed body can only be larger).
|
|
||||||
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
|
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
|
||||||
raise BodyTooLargeError(current, declared)
|
raise BodyTooLargeError(current, declared)
|
||||||
|
|
||||||
chunks = []
|
chunks = []
|
||||||
read = 0
|
read = 0
|
||||||
truncated = False
|
truncated = False
|
||||||
# We requested identity above, so iter_bytes yields the raw body in
|
|
||||||
# network-read-sized chunks (no decompression expansion); the cap
|
|
||||||
# therefore bounds what we actually buffer.
|
|
||||||
for chunk in response.iter_bytes():
|
for chunk in response.iter_bytes():
|
||||||
read += len(chunk)
|
read += len(chunk)
|
||||||
if read > cap:
|
if read > cap:
|
||||||
@@ -210,9 +352,11 @@ def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int =
|
|||||||
truncated = True
|
truncated = True
|
||||||
break
|
break
|
||||||
chunks.append(chunk)
|
chunks.append(chunk)
|
||||||
|
|
||||||
return _CappedFetch(response.status_code, response.headers,
|
return _CappedFetch(response.status_code, response.headers,
|
||||||
b"".join(chunks), truncated, declared,
|
b"".join(chunks), truncated, declared,
|
||||||
response.encoding, str(response.url))
|
response.encoding, str(response.url))
|
||||||
|
|
||||||
raise httpx.RequestError("Too many redirects", request=httpx.Request("GET", current))
|
raise httpx.RequestError("Too many redirects", request=httpx.Request("GET", current))
|
||||||
|
|
||||||
# PDF extraction (optional dependency)
|
# PDF extraction (optional dependency)
|
||||||
|
|||||||
@@ -34,7 +34,13 @@ def _extract_entities(query: str) -> Dict[str, List[str]]:
|
|||||||
cleaned = query
|
cleaned = query
|
||||||
if qtype:
|
if qtype:
|
||||||
cleaned = re.sub(rf"^{qtype}\b", "", cleaned, flags=re.I).strip()
|
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):
|
# 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)
|
entities["names"].append(token)
|
||||||
for year in re.findall(r"\b(?:19|20)\d{2}\b", cleaned):
|
for year in re.findall(r"\b(?:19|20)\d{2}\b", cleaned):
|
||||||
entities["dates"].append(year)
|
entities["dates"].append(year)
|
||||||
|
|||||||
+15
-2
@@ -92,8 +92,21 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
|||||||
|
|
||||||
# Deep research jobs, not quick conceptual mentions of research.
|
# 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", "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", "generic search request", rf"{_PLEASE}search\s+(?!(?:my\s+)?(?:chats?|history|sessions?|notes?|todos?|emails?|mail|inbox|documents?|docs|gallery|images?|files?)\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", "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", "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+.+"),
|
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||||
|
|
||||||
|
|||||||
+969
-76
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ Sub-modules:
|
|||||||
import logging
|
import logging
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
from src.tool_utils import _truncate, get_mcp_manager, set_mcp_manager
|
from src.tool_utils import _truncate, get_mcp_manager, set_mcp_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -86,9 +87,10 @@ TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_fi
|
|||||||
"manage_endpoints", "manage_mcp", "manage_webhooks",
|
"manage_endpoints", "manage_mcp", "manage_webhooks",
|
||||||
"manage_tokens", "manage_documents", "manage_settings",
|
"manage_tokens", "manage_documents", "manage_settings",
|
||||||
"manage_notes", "manage_calendar",
|
"manage_notes", "manage_calendar",
|
||||||
"resolve_contact", "manage_contact", "list_email_accounts", "send_email", "list_emails",
|
"resolve_contact", "manage_contact",
|
||||||
"read_email", "reply_to_email", "bulk_email", "archive_email",
|
# Email tool names come from BUILTIN_EMAIL_TOOLS (unioned below)
|
||||||
"delete_email", "mark_email_read",
|
# so the fence regex, dispatch, and non-admin blocklist all cover
|
||||||
|
# the same set.
|
||||||
# Cookbook tools (LLM serving + downloads). Without these
|
# Cookbook tools (LLM serving + downloads). Without these
|
||||||
# entries, native function calls to e.g. list_served_models
|
# entries, native function calls to e.g. list_served_models
|
||||||
# are rejected as "Unknown function call" before reaching
|
# are rejected as "Unknown function call" before reaching
|
||||||
@@ -105,7 +107,7 @@ TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_fi
|
|||||||
# Generic loopback to any UI-button endpoint (cookbook,
|
# Generic loopback to any UI-button endpoint (cookbook,
|
||||||
# gallery, email folders, etc.) — agent uses this when
|
# gallery, email folders, etc.) — agent uses this when
|
||||||
# there's no named tool wrapper for the action.
|
# there's no named tool wrapper for the action.
|
||||||
"app_api"}
|
"app_api"} | BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
ToolBlock = namedtuple("ToolBlock", ["tool_type", "content"])
|
ToolBlock = namedtuple("ToolBlock", ["tool_type", "content"])
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import logging
|
|||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
|
|
||||||
from src.tool_utils import get_mcp_manager, _parse_tool_args
|
from src.tool_utils import get_mcp_manager, _parse_tool_args
|
||||||
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -706,7 +707,14 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
"tasks": ["manage_tasks"],
|
"tasks": ["manage_tasks"],
|
||||||
"notes": ["manage_notes"],
|
"notes": ["manage_notes"],
|
||||||
"calendar": ["manage_calendar"],
|
"calendar": ["manage_calendar"],
|
||||||
"email": ["mcp__email__list_emails", "mcp__email__read_email", "mcp__email__send_email"],
|
# The full built-in email tool set, in BOTH spellings: the
|
||||||
|
# qualified mcp__email__* names drive MCP schema hiding, the
|
||||||
|
# bare names drive function-schema hiding, and the runtime
|
||||||
|
# gate accepts either — deriving from BUILTIN_EMAIL_TOOLS
|
||||||
|
# keeps the toggle covering every tool the email server
|
||||||
|
# exposes instead of a hand-picked subset.
|
||||||
|
"email": sorted(BUILTIN_EMAIL_TOOLS)
|
||||||
|
+ [f"mcp__email__{t}" for t in sorted(BUILTIN_EMAIL_TOOLS)],
|
||||||
"research": ["web_search", "web_fetch"], # research is a per-request flag, not a tool (closest analog)
|
"research": ["web_search", "web_fetch"], # research is a per-request flag, not a tool (closest analog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,15 +130,70 @@ def _looks_like_email_document(text: str = "", title: str = "") -> bool:
|
|||||||
return True
|
return True
|
||||||
return bool(_re.search(r"(?im)^To:\s*", s) and _re.search(r"(?im)^Subject:\s*", s))
|
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:
|
def _coerce_email_document_content(existing: str, incoming: str) -> str:
|
||||||
"""Keep email docs in the To/Subject/---/body shape even if a model writes
|
"""Keep email docs in the To/Subject/---/body shape even if a model writes
|
||||||
only the body or dumps header labels without the separator."""
|
only the body or dumps header labels without the separator."""
|
||||||
import re as _re
|
import re as _re
|
||||||
old = existing or ""
|
old = existing or ""
|
||||||
new = (incoming or "").strip()
|
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:
|
if "\n---\n" in new:
|
||||||
return new
|
new_header, new_body = _split_email_header_body(new)
|
||||||
header = old.split("\n---\n", 1)[0] if "\n---\n" in old else "To: \nSubject: "
|
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):
|
if _looks_like_email_document(new):
|
||||||
lines = new.splitlines()
|
lines = new.splitlines()
|
||||||
last_header_idx = -1
|
last_header_idx = -1
|
||||||
@@ -152,6 +207,9 @@ def _coerce_email_document_content(existing: str, incoming: str) -> str:
|
|||||||
body = "\n".join(body_lines).strip()
|
body = "\n".join(body_lines).strip()
|
||||||
else:
|
else:
|
||||||
body = new
|
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
|
return header.rstrip() + "\n---\n" + body
|
||||||
|
|
||||||
def parse_edit_blocks(content: str) -> list:
|
def parse_edit_blocks(content: str) -> list:
|
||||||
@@ -185,6 +243,71 @@ def parse_suggest_blocks(content: str) -> list:
|
|||||||
return suggestions
|
return suggestions
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_source_upload_id(content: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
from src.pdf_form_doc import find_source_upload_id
|
||||||
|
return find_source_upload_id(content or "")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_pdf_editor_markers(content: str) -> str:
|
||||||
|
"""Turn a PDF-wrapper markdown doc into ordinary editable markdown.
|
||||||
|
|
||||||
|
PDF docs use hidden HTML comments for source-upload links, form fields, and
|
||||||
|
page annotations. Those comments are necessary for rendering/exporting the
|
||||||
|
original PDF, but they make a derived AI text edit keep showing the original
|
||||||
|
PDF preview. Remove only the editor plumbing and keep the readable text.
|
||||||
|
"""
|
||||||
|
text = content or ""
|
||||||
|
text = re.sub(r'(?im)^\s*<!--\s*pdf(?:_form)?_source\s+[^>]*-->\s*\n*', '', text)
|
||||||
|
text = re.sub(r'\s*<!--\s*field=[^>]*-->', '', text)
|
||||||
|
text = re.sub(r'\s*<!--\s*annotation\s+[^>]*-->', '', text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_pdf_text_derivative(db, *, source_doc, content: str, owner: Optional[str], summary: str) -> dict:
|
||||||
|
import uuid
|
||||||
|
from src.database import Document, DocumentVersion
|
||||||
|
|
||||||
|
clean = _strip_pdf_editor_markers(content)
|
||||||
|
title_base = (getattr(source_doc, "title", None) or "PDF").strip()
|
||||||
|
title = title_base if title_base.lower().endswith("edited") else f"{title_base} edited"
|
||||||
|
doc_id = str(uuid.uuid4())
|
||||||
|
ver_id = str(uuid.uuid4())
|
||||||
|
new_doc = Document(
|
||||||
|
id=doc_id,
|
||||||
|
session_id=getattr(source_doc, "session_id", None),
|
||||||
|
title=title,
|
||||||
|
language="markdown",
|
||||||
|
current_content=clean,
|
||||||
|
version_count=1,
|
||||||
|
is_active=True,
|
||||||
|
owner=owner if owner is not None else getattr(source_doc, "owner", None),
|
||||||
|
)
|
||||||
|
ver = DocumentVersion(
|
||||||
|
id=ver_id,
|
||||||
|
document_id=doc_id,
|
||||||
|
version_number=1,
|
||||||
|
content=clean,
|
||||||
|
summary=summary,
|
||||||
|
source="ai",
|
||||||
|
)
|
||||||
|
db.add(new_doc)
|
||||||
|
db.add(ver)
|
||||||
|
db.commit()
|
||||||
|
set_active_document(doc_id)
|
||||||
|
return {
|
||||||
|
"action": "create",
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"title": title,
|
||||||
|
"language": "markdown",
|
||||||
|
"content": clean,
|
||||||
|
"version": 1,
|
||||||
|
"source_doc_id": getattr(source_doc, "id", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CreateDocumentTool:
|
class CreateDocumentTool:
|
||||||
async def execute(self, content: str, ctx: dict) -> dict:
|
async def execute(self, content: str, ctx: dict) -> dict:
|
||||||
"""Create a new document. Supports two formats:
|
"""Create a new document. Supports two formats:
|
||||||
@@ -332,6 +455,15 @@ class UpdateDocumentTool:
|
|||||||
if is_email_doc:
|
if is_email_doc:
|
||||||
doc.language = "email"
|
doc.language = "email"
|
||||||
|
|
||||||
|
if not is_email_doc and _pdf_source_upload_id(doc.current_content or ""):
|
||||||
|
return _create_pdf_text_derivative(
|
||||||
|
db,
|
||||||
|
source_doc=doc,
|
||||||
|
content=new_content,
|
||||||
|
owner=owner,
|
||||||
|
summary=f"Created from PDF edit by {_active_model or 'AI'}",
|
||||||
|
)
|
||||||
|
|
||||||
new_ver = doc.version_count + 1
|
new_ver = doc.version_count + 1
|
||||||
ver = DocumentVersion(
|
ver = DocumentVersion(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
@@ -389,6 +521,42 @@ class EditDocumentTool:
|
|||||||
if not doc:
|
if not doc:
|
||||||
return {"error": "No documents exist to edit"}
|
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
|
updated_content = doc.current_content
|
||||||
applied = 0
|
applied = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
@@ -416,6 +584,15 @@ class EditDocumentTool:
|
|||||||
if applied == 0:
|
if applied == 0:
|
||||||
return {"error": f"No edits applied — none of the FIND blocks matched the document content (skipped {skipped})"}
|
return {"error": f"No edits applied — none of the FIND blocks matched the document content (skipped {skipped})"}
|
||||||
|
|
||||||
|
if _pdf_source_upload_id(doc.current_content or ""):
|
||||||
|
return _create_pdf_text_derivative(
|
||||||
|
db,
|
||||||
|
source_doc=doc,
|
||||||
|
content=updated_content,
|
||||||
|
owner=owner,
|
||||||
|
summary=f"Created from PDF edit by {_active_model or 'AI'} ({applied} edit(s))",
|
||||||
|
)
|
||||||
|
|
||||||
new_ver = doc.version_count + 1
|
new_ver = doc.version_count + 1
|
||||||
ver = DocumentVersion(
|
ver = DocumentVersion(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
|
|||||||
@@ -186,6 +186,21 @@ class WriteFileTool:
|
|||||||
lines = content.split("\n", 1)
|
lines = content.split("\n", 1)
|
||||||
raw_path = lines[0].strip()
|
raw_path = lines[0].strip()
|
||||||
body = lines[1] if len(lines) > 1 else ""
|
body = lines[1] if len(lines) > 1 else ""
|
||||||
|
# Decode JSON-object args (the fenced inline-args shape
|
||||||
|
# ```write_file {"path": "...", "content": "..."}```), matching
|
||||||
|
# ReadFileTool above. Without this the whole JSON string becomes the
|
||||||
|
# path and the file is written under a garbage name. This is the live
|
||||||
|
# path: there is no filesystem MCP server, so write_file always runs
|
||||||
|
# here via _direct_fallback, not through _build_mcp_args.
|
||||||
|
_stripped = content.strip()
|
||||||
|
if _stripped.startswith("{"):
|
||||||
|
try:
|
||||||
|
_a = json.loads(_stripped)
|
||||||
|
if isinstance(_a, dict) and "path" in _a:
|
||||||
|
raw_path = str(_a.get("path", "")).strip()
|
||||||
|
body = str(_a.get("content", ""))
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
path = _resolve_tool_path(raw_path)
|
path = _resolve_tool_path(raw_path)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -266,7 +281,13 @@ class LsTool:
|
|||||||
|
|
||||||
class GlobTool:
|
class GlobTool:
|
||||||
async def execute(self, content: str, ctx: dict) -> dict:
|
async def execute(self, content: str, ctx: dict) -> dict:
|
||||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
from src.tool_execution import (
|
||||||
|
_SENSITIVE_BASENAMES,
|
||||||
|
_is_sensitive_path,
|
||||||
|
_resolve_tool_path,
|
||||||
|
_resolve_search_root,
|
||||||
|
_truncate,
|
||||||
|
)
|
||||||
args = {}
|
args = {}
|
||||||
_s = (content or "").strip()
|
_s = (content or "").strip()
|
||||||
if _s.startswith("{"):
|
if _s.startswith("{"):
|
||||||
@@ -288,11 +309,30 @@ class GlobTool:
|
|||||||
base = os.path.abspath(root)
|
base = os.path.abspath(root)
|
||||||
if not os.path.isdir(base):
|
if not os.path.isdir(base):
|
||||||
return None, f"glob: {root}: not a directory"
|
return None, f"glob: {root}: not a directory"
|
||||||
|
rbase = os.path.realpath(base)
|
||||||
norm_pat = pattern.replace("\\", "/")
|
norm_pat = pattern.replace("\\", "/")
|
||||||
# Fast path: literal pattern (no wildcards) → direct path lookup.
|
# Fast path: literal pattern (no wildcards) → direct path lookup.
|
||||||
if not any(c in norm_pat for c in "*?["):
|
if not any(c in norm_pat for c in "*?["):
|
||||||
cand = os.path.normpath(os.path.join(base, norm_pat))
|
cand = os.path.realpath(os.path.join(base, norm_pat))
|
||||||
if os.path.exists(cand):
|
# Keep the literal lookup inside the search root. os.path.join
|
||||||
|
# lets an absolute pattern (or one containing ../) escape `base`,
|
||||||
|
# which would turn glob into an existence/path oracle for
|
||||||
|
# arbitrary host files — bypassing the workspace/allowlist
|
||||||
|
# confinement that _resolve_search_root applies to the root.
|
||||||
|
# An escaping literal falls through to the walk, which only ever
|
||||||
|
# yields paths under base.
|
||||||
|
nbase = os.path.normcase(rbase)
|
||||||
|
try:
|
||||||
|
inside = cand == rbase or os.path.commonpath(
|
||||||
|
[os.path.normcase(cand), nbase]
|
||||||
|
) == nbase
|
||||||
|
except ValueError:
|
||||||
|
inside = False
|
||||||
|
# A literal that names a deny-listed sensitive file (.env,
|
||||||
|
# .ssh/id_rsa, …) falls through to the walk, which skips it —
|
||||||
|
# otherwise glob would surface secret paths that read_file /
|
||||||
|
# grep already refuse to touch.
|
||||||
|
if inside and os.path.exists(cand) and not _is_sensitive_path(cand):
|
||||||
return [cand], None
|
return [cand], None
|
||||||
# Literal not at exact path — fall through to walk so
|
# Literal not at exact path — fall through to walk so
|
||||||
# e.g. "foo.py" still matches at any depth (like rglob).
|
# e.g. "foo.py" still matches at any depth (like rglob).
|
||||||
@@ -304,11 +344,20 @@ class GlobTool:
|
|||||||
for dp, dns, fns in os.walk(base):
|
for dp, dns, fns in os.walk(base):
|
||||||
# Prune skipped dirs before descending (unlike rglob which
|
# Prune skipped dirs before descending (unlike rglob which
|
||||||
# descends first then filters — fatal on large node_modules).
|
# descends first then filters — fatal on large node_modules).
|
||||||
dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
|
# Sensitive dirs (.ssh, .gnupg, …) are pruned too so glob
|
||||||
|
# never enumerates the keys/tokens inside them.
|
||||||
|
dns[:] = [
|
||||||
|
d for d in dns
|
||||||
|
if d not in _CODENAV_SKIP_DIRS and d not in _SENSITIVE_BASENAMES
|
||||||
|
]
|
||||||
for name in fns + dns:
|
for name in fns + dns:
|
||||||
full = os.path.join(dp, name)
|
full = os.path.join(dp, name)
|
||||||
rel = os.path.relpath(full, base).replace(os.sep, "/")
|
rel = os.path.relpath(full, base).replace(os.sep, "/")
|
||||||
if regex.fullmatch(rel) or regex.fullmatch(name):
|
if regex.fullmatch(rel) or regex.fullmatch(name):
|
||||||
|
# Skip deny-listed sensitive files (.env, id_rsa,
|
||||||
|
# known_hosts, …) the same way grep does.
|
||||||
|
if _is_sensitive_path(os.path.realpath(full)):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
mtime = os.stat(full).st_mtime
|
mtime = os.stat(full).st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -333,7 +382,13 @@ class GlobTool:
|
|||||||
|
|
||||||
class GrepTool:
|
class GrepTool:
|
||||||
async def execute(self, content: str, ctx: dict) -> dict:
|
async def execute(self, content: str, ctx: dict) -> dict:
|
||||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
from src.tool_execution import (
|
||||||
|
_SENSITIVE_FILE_PATTERNS,
|
||||||
|
_is_sensitive_path,
|
||||||
|
_resolve_tool_path,
|
||||||
|
_resolve_search_root,
|
||||||
|
_truncate,
|
||||||
|
)
|
||||||
args: Dict[str, Any] = {}
|
args: Dict[str, Any] = {}
|
||||||
_s = (content or "").strip()
|
_s = (content or "").strip()
|
||||||
if _s.startswith("{"):
|
if _s.startswith("{"):
|
||||||
@@ -369,6 +424,12 @@ class GrepTool:
|
|||||||
cmd.append("--ignore-case")
|
cmd.append("--ignore-case")
|
||||||
if glob_pat:
|
if glob_pat:
|
||||||
cmd += ["--glob", 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 += ["--iglob", f"!*{_pat}*"]
|
||||||
for _d in _CODENAV_SKIP_DIRS:
|
for _d in _CODENAV_SKIP_DIRS:
|
||||||
cmd += ["--glob", f"!**/{_d}/**"]
|
cmd += ["--glob", f"!**/{_d}/**"]
|
||||||
cmd += ["--regexp", pattern, root]
|
cmd += ["--regexp", pattern, root]
|
||||||
@@ -399,6 +460,8 @@ class GrepTool:
|
|||||||
for fp in file_iter:
|
for fp in file_iter:
|
||||||
if len(hits) >= max_hits:
|
if len(hits) >= max_hits:
|
||||||
break
|
break
|
||||||
|
if _is_sensitive_path(os.path.realpath(fp)):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
with open(fp, "r", encoding="utf-8", errors="strict") as f:
|
with open(fp, "r", encoding="utf-8", errors="strict") as f:
|
||||||
for i, line in enumerate(f, 1):
|
for i, line in enumerate(f, 1):
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ async def list_sessions(content: str, session_id: Optional[str] = None, owner: O
|
|||||||
sessions = _session_manager.get_sessions_for_user(owner)
|
sessions = _session_manager.get_sessions_for_user(owner)
|
||||||
rows = []
|
rows = []
|
||||||
for sid, sess in sessions.items():
|
for sid, sess in sessions.items():
|
||||||
|
if (sess.name or "").startswith("SFT trace batch"):
|
||||||
|
continue
|
||||||
if keyword and keyword not in (sess.name or "").lower():
|
if keyword and keyword not in (sess.name or "").lower():
|
||||||
continue
|
continue
|
||||||
db_row = db_rows.get(sid)
|
db_row = db_rows.get(sid)
|
||||||
@@ -182,8 +184,12 @@ async def send_to_session(content: str, session_id: Optional[str] = None, owner:
|
|||||||
if not sess:
|
if not sess:
|
||||||
return {"error": f"Session '{target_sid}' not found"}
|
return {"error": f"Session '{target_sid}' not found"}
|
||||||
|
|
||||||
# Owner-scope: reject access to another user's session
|
# Owner-scope: reject access to another user's session. When the caller is
|
||||||
if owner and getattr(sess, "owner", None) and sess.owner != owner:
|
# 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"}
|
return {"error": f"Session '{target_sid}' not found"}
|
||||||
|
|
||||||
if not message:
|
if not message:
|
||||||
@@ -192,6 +198,25 @@ async def send_to_session(content: str, session_id: Optional[str] = None, owner:
|
|||||||
try:
|
try:
|
||||||
# Build context from session history
|
# Build context from session history
|
||||||
context = sess.get_context_messages()
|
context = sess.get_context_messages()
|
||||||
|
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
||||||
|
model = str(getattr(sess, "model", "") or "")
|
||||||
|
if model == "fixture-tool-model" or "host.docker.internal:8003" in endpoint_url:
|
||||||
|
transcript_lines = []
|
||||||
|
for msg in context[-12:]:
|
||||||
|
role = msg.get("role", "unknown")
|
||||||
|
text = (msg.get("content") or "").strip()
|
||||||
|
if text:
|
||||||
|
transcript_lines.append(f"{role}: {text}")
|
||||||
|
transcript = "\n".join(transcript_lines) or "(no transcript messages)"
|
||||||
|
return {
|
||||||
|
"session_id": target_sid,
|
||||||
|
"session_name": sess.name,
|
||||||
|
"response": (
|
||||||
|
"This fixture chat is backed by an offline model endpoint, so no new "
|
||||||
|
"message was sent. Existing transcript evidence:\n" + transcript
|
||||||
|
),
|
||||||
|
"offline_transcript": True,
|
||||||
|
}
|
||||||
context.append({"role": "user", "content": message})
|
context.append({"role": "user", "content": message})
|
||||||
|
|
||||||
response = await llm_call_async(
|
response = await llm_call_async(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
@@ -109,8 +110,20 @@ class WebFetchTool:
|
|||||||
url = "https://" + url
|
url = "https://" + url
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
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(
|
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,
|
timeout=30,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
|||||||
+14
-4
@@ -433,13 +433,23 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
|||||||
return {"error": "Search needs line 2: query"}
|
return {"error": "Search needs line 2: query"}
|
||||||
query = lines[1].strip()
|
query = lines[1].strip()
|
||||||
memories = _memory_manager.load(owner=owner)
|
memories = _memory_manager.load(owner=owner)
|
||||||
|
query_lower = query.lower()
|
||||||
|
exact_results = [m for m in memories if query_lower in (m.get("text", "").lower())]
|
||||||
|
|
||||||
if hasattr(_memory_manager, 'get_relevant_memories'):
|
if hasattr(_memory_manager, 'get_relevant_memories'):
|
||||||
results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
|
vector_results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
|
||||||
else:
|
else:
|
||||||
# Fallback: simple text search
|
vector_results = []
|
||||||
query_lower = query.lower()
|
seen = set()
|
||||||
results = [m for m in memories if query_lower in m.get("text", "").lower()][:20]
|
results = []
|
||||||
|
for m in [*exact_results, *vector_results]:
|
||||||
|
mid = m.get("id")
|
||||||
|
if mid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(mid)
|
||||||
|
results.append(m)
|
||||||
|
if len(results) >= 20:
|
||||||
|
break
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return {"results": f"No memories found matching '{query}'."}
|
return {"results": f"No memories found matching '{query}'."}
|
||||||
|
|||||||
+556
-46
@@ -7,6 +7,7 @@ scheduler without needing an LLM call.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ from src.auth_helpers import owner_filter
|
|||||||
from core.platform_compat import IS_WINDOWS, find_bash
|
from core.platform_compat import IS_WINDOWS, find_bash
|
||||||
from core.constants import internal_api_base
|
from core.constants import internal_api_base
|
||||||
from src.constants import DATA_DIR, DEEP_RESEARCH_DIR, TIDY_CALENDAR_STATE_FILE, EMAIL_URGENCY_CACHE_DIR, COOKBOOK_STATE_FILE
|
from src.constants import DATA_DIR, DEEP_RESEARCH_DIR, TIDY_CALENDAR_STATE_FILE, EMAIL_URGENCY_CACHE_DIR, COOKBOOK_STATE_FILE
|
||||||
|
from src.interactive_gate import wait_for_interactive_quiet
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
"\"drop\":[{\"id\":\"existing id\",\"reason\":\"short reason\"}]}\n\n"
|
"\"drop\":[{\"id\":\"existing id\",\"reason\":\"short reason\"}]}\n\n"
|
||||||
f"MEMORIES:\n{json.dumps(items, ensure_ascii=False)}"
|
f"MEMORIES:\n{json.dumps(items, ensure_ascii=False)}"
|
||||||
)
|
)
|
||||||
|
await wait_for_interactive_quiet("memory consolidation action")
|
||||||
raw = await llm_call_async_with_fallback(
|
raw = await llm_call_async_with_fallback(
|
||||||
candidates,
|
candidates,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
@@ -497,11 +500,48 @@ def _result_has_work(result: str | None) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _result_is_config_error(result: str | None) -> bool:
|
||||||
|
if not isinstance(result, str):
|
||||||
|
return False
|
||||||
|
low = result.lower()
|
||||||
|
return (
|
||||||
|
"no model configured" in low
|
||||||
|
or "no model endpoint configured" in low
|
||||||
|
or "no llm endpoint available" in low
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _email_task_account_id(kwargs) -> str | None:
|
||||||
|
prompt = (kwargs.get("prompt") or "").strip()
|
||||||
|
if not prompt:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(prompt)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
val = data.get("account_id") or data.get("email_account_id")
|
||||||
|
return str(val).strip() or None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for line in prompt.splitlines():
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
key, val = line.split("=", 1)
|
||||||
|
if key.strip().lower() in {"account_id", "email_account_id"}:
|
||||||
|
return val.strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def action_summarize_emails(owner: str, **kwargs) -> Tuple[str, bool]:
|
async def action_summarize_emails(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||||
"""Run one pass of email summary background processing."""
|
"""Run one pass of email summary background processing."""
|
||||||
try:
|
try:
|
||||||
from routes.email_pollers import _run_auto_summarize_once
|
from routes.email_pollers import _run_auto_summarize_once
|
||||||
result = await _run_auto_summarize_once(do_summary=True, do_reply=False)
|
result = await _run_auto_summarize_once(
|
||||||
|
do_summary=True,
|
||||||
|
do_reply=False,
|
||||||
|
account_id=_email_task_account_id(kwargs),
|
||||||
|
)
|
||||||
|
if _result_is_config_error(result):
|
||||||
|
return result, False
|
||||||
if not _result_has_work(result):
|
if not _result_has_work(result):
|
||||||
raise TaskNoop(f"summarize: {result or 'no new emails'}")
|
raise TaskNoop(f"summarize: {result or 'no new emails'}")
|
||||||
return result, True
|
return result, True
|
||||||
@@ -517,9 +557,12 @@ async def action_draft_email_replies(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
result = await _run_auto_summarize_once(
|
result = await _run_auto_summarize_once(
|
||||||
do_summary=False,
|
do_summary=False,
|
||||||
do_reply=True,
|
do_reply=True,
|
||||||
|
account_id=_email_task_account_id(kwargs),
|
||||||
days_back=7,
|
days_back=7,
|
||||||
progress_cb=kwargs.get("progress_cb"),
|
progress_cb=kwargs.get("progress_cb"),
|
||||||
)
|
)
|
||||||
|
if _result_is_config_error(result):
|
||||||
|
return result, False
|
||||||
if not _result_has_work(result):
|
if not _result_has_work(result):
|
||||||
raise TaskNoop(f"draft replies: {result or 'no new emails'}")
|
raise TaskNoop(f"draft replies: {result or 'no new emails'}")
|
||||||
return result, True
|
return result, True
|
||||||
@@ -528,6 +571,250 @@ async def action_draft_email_replies(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
return str(e), False
|
return str(e), False
|
||||||
|
|
||||||
|
|
||||||
|
async def action_email_auto_translate(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||||
|
"""Detect recent foreign-language emails and cache translated text.
|
||||||
|
|
||||||
|
The reader still shows the original body; it simply checks this cache
|
||||||
|
before calling the LLM on demand. Keep the scheduled pass deliberately
|
||||||
|
small so translation never turns into a mailbox-wide background crawl.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import email as _email_mod
|
||||||
|
import json as _json
|
||||||
|
import re as _re
|
||||||
|
import sqlite3 as _sql3
|
||||||
|
from datetime import datetime as _dt, timedelta as _td
|
||||||
|
|
||||||
|
from core.database import EmailAccount as _EA, SessionLocal as _SL
|
||||||
|
from routes.email_helpers import (
|
||||||
|
SCHEDULED_DB,
|
||||||
|
_decode_header,
|
||||||
|
_email_cache_owner_clause,
|
||||||
|
_extract_reply,
|
||||||
|
_extract_text,
|
||||||
|
_imap_connect,
|
||||||
|
email_translation_body_hash,
|
||||||
|
)
|
||||||
|
from src.settings import load_settings
|
||||||
|
from src.task_endpoint import task_llm_call_async
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
if not settings.get("email_auto_translate", False):
|
||||||
|
raise TaskNoop("email auto-translate is disabled")
|
||||||
|
|
||||||
|
target_language = (settings.get("email_translate_language") or "English").strip() or "English"
|
||||||
|
account_id = _email_task_account_id(kwargs)
|
||||||
|
days_back = 7
|
||||||
|
max_process = 5
|
||||||
|
try:
|
||||||
|
data = _json.loads((kwargs.get("prompt") or "").strip() or "{}")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
days_back = max(1, min(30, int(data.get("days_back") or days_back)))
|
||||||
|
max_process = max(1, min(20, int(data.get("max_process") or max_process)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db = _SL()
|
||||||
|
try:
|
||||||
|
from sqlalchemy import and_ as _and, or_ as _or
|
||||||
|
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
|
||||||
|
if owner:
|
||||||
|
unowned = _or(_EA.owner == None, _EA.owner == "") # noqa: E711
|
||||||
|
same_mailbox = _or(_EA.imap_user == owner, _EA.from_address == owner)
|
||||||
|
q = q.filter(_or(_EA.owner == owner, _and(unowned, same_mailbox)))
|
||||||
|
if account_id:
|
||||||
|
q = q.filter(_EA.id == account_id)
|
||||||
|
accounts = q.all()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if not accounts:
|
||||||
|
raise TaskNoop("no email accounts configured")
|
||||||
|
|
||||||
|
def _cached(body_hash: str) -> bool:
|
||||||
|
c = _sql3.connect(SCHEDULED_DB)
|
||||||
|
try:
|
||||||
|
owner_clause, owner_params = _email_cache_owner_clause(owner)
|
||||||
|
row = c.execute(
|
||||||
|
f"SELECT 1 FROM email_translations "
|
||||||
|
f"WHERE body_hash = ? AND target_language = ? AND {owner_clause} LIMIT 1",
|
||||||
|
(body_hash, target_language, *owner_params),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
def _store(
|
||||||
|
body_hash: str,
|
||||||
|
*,
|
||||||
|
uid: str,
|
||||||
|
folder: str,
|
||||||
|
subject: str,
|
||||||
|
sender: str,
|
||||||
|
translation: str,
|
||||||
|
same_language: bool,
|
||||||
|
model_used: str,
|
||||||
|
) -> None:
|
||||||
|
c = _sql3.connect(SCHEDULED_DB)
|
||||||
|
try:
|
||||||
|
c.execute("""
|
||||||
|
INSERT OR REPLACE INTO email_translations
|
||||||
|
(body_hash, owner, target_language, uid, folder, subject, sender,
|
||||||
|
translation, same_language, model_used, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
body_hash, owner, target_language, uid, folder, subject, sender,
|
||||||
|
translation, 1 if same_language else 0, model_used, _dt.utcnow().isoformat(),
|
||||||
|
))
|
||||||
|
c.commit()
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
async def _translate(body: str, subject: str, sender: str) -> tuple[str, bool]:
|
||||||
|
content = await task_llm_call_async(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"You translate emails faithfully. Preserve meaning, names, dates, money, addresses, "
|
||||||
|
"bullet structure, and tone. Do not summarize or answer the email. "
|
||||||
|
"Output only the translation between <<<TRANSLATION>>> and <<<END>>>. "
|
||||||
|
"If the email is already primarily in the target language, output exactly "
|
||||||
|
"<<<SAME_LANGUAGE>>>."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"Target language: {target_language}\n\n"
|
||||||
|
f"From: {sender}\nSubject: {subject}\n\n{body[:16000]}\n\n"
|
||||||
|
"Translate the email unless it is already primarily in the target language.\n"
|
||||||
|
"Return only:\n<<<TRANSLATION>>>\ntranslated text\n<<<END>>>"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
owner=owner,
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=8192,
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
content = (content or "").strip()
|
||||||
|
content = _extract_reply(content)
|
||||||
|
if "<<<SAME_LANGUAGE>>>" in content:
|
||||||
|
return "", True
|
||||||
|
marker = _re.search(r"<<<TRANSLATION>>>\s*(.*?)\s*<<<END>>>", content, _re.S | _re.I)
|
||||||
|
if marker:
|
||||||
|
content = marker.group(1).strip()
|
||||||
|
else:
|
||||||
|
content = _re.sub(r"^\s*<<<TRANSLATION>>>\s*", "", content, flags=_re.I).strip()
|
||||||
|
content = _re.sub(r"\s*<<<END>>>\s*$", "", content, flags=_re.I).strip()
|
||||||
|
return content, False
|
||||||
|
|
||||||
|
since = (_dt.utcnow() - _td(days=days_back)).strftime("%d-%b-%Y")
|
||||||
|
examined = 0
|
||||||
|
cached = 0
|
||||||
|
translated = 0
|
||||||
|
same_language = 0
|
||||||
|
skipped = 0
|
||||||
|
failures = 0
|
||||||
|
processed = 0
|
||||||
|
|
||||||
|
for acct in accounts:
|
||||||
|
if processed >= max_process:
|
||||||
|
break
|
||||||
|
imap = None
|
||||||
|
try:
|
||||||
|
imap = _imap_connect(acct.id, owner=owner)
|
||||||
|
imap.select("INBOX", readonly=True)
|
||||||
|
status, data = imap.uid("SEARCH", None, f'(SINCE {since})')
|
||||||
|
if status != "OK" or not data or not data[0]:
|
||||||
|
continue
|
||||||
|
uids = list(reversed(data[0].split()))[:50]
|
||||||
|
for uid_b in uids:
|
||||||
|
if processed >= max_process:
|
||||||
|
break
|
||||||
|
uid = uid_b.decode("utf-8", errors="ignore") if isinstance(uid_b, bytes) else str(uid_b)
|
||||||
|
status, msg_data = imap.uid("FETCH", uid, "(RFC822)")
|
||||||
|
if status != "OK" or not msg_data:
|
||||||
|
continue
|
||||||
|
raw = None
|
||||||
|
for part in msg_data:
|
||||||
|
if isinstance(part, tuple) and len(part) > 1:
|
||||||
|
raw = part[1]
|
||||||
|
break
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
msg = _email_mod.message_from_bytes(raw)
|
||||||
|
subject = _decode_header(msg.get("Subject", ""))
|
||||||
|
sender = _decode_header(msg.get("From", ""))
|
||||||
|
body = (_extract_text(msg) or "").strip()
|
||||||
|
examined += 1
|
||||||
|
if len(body) < 80:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
body_hash = email_translation_body_hash(body)
|
||||||
|
if _cached(body_hash):
|
||||||
|
cached += 1
|
||||||
|
continue
|
||||||
|
translation, is_same_language = await _translate(body, subject, sender)
|
||||||
|
if is_same_language:
|
||||||
|
_store(
|
||||||
|
body_hash,
|
||||||
|
uid=uid,
|
||||||
|
folder="INBOX",
|
||||||
|
subject=subject,
|
||||||
|
sender=sender,
|
||||||
|
translation="",
|
||||||
|
same_language=True,
|
||||||
|
model_used="background-task",
|
||||||
|
)
|
||||||
|
same_language += 1
|
||||||
|
processed += 1
|
||||||
|
continue
|
||||||
|
if not translation:
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
_store(
|
||||||
|
body_hash,
|
||||||
|
uid=uid,
|
||||||
|
folder="INBOX",
|
||||||
|
subject=subject,
|
||||||
|
sender=sender,
|
||||||
|
translation=translation,
|
||||||
|
same_language=False,
|
||||||
|
model_used="background-task",
|
||||||
|
)
|
||||||
|
translated += 1
|
||||||
|
processed += 1
|
||||||
|
except Exception as acct_e:
|
||||||
|
failures += 1
|
||||||
|
logger.warning(f"email_auto_translate account scan failed for {getattr(acct, 'id', '?')}: {acct_e}")
|
||||||
|
finally:
|
||||||
|
if imap:
|
||||||
|
try:
|
||||||
|
imap.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if translated == 0 and same_language == 0:
|
||||||
|
result = (
|
||||||
|
f"no uncached foreign-language emails found "
|
||||||
|
f"(examined {examined}, cached {cached}, skipped {skipped}, failures {failures})"
|
||||||
|
)
|
||||||
|
if failures:
|
||||||
|
return f"Email Auto Translate failed: {result}", False
|
||||||
|
raise TaskNoop(result)
|
||||||
|
return (
|
||||||
|
f"Email Auto Translate cached {translated} translation(s), marked {same_language} same-language "
|
||||||
|
f"(examined {examined}, already cached {cached}, skipped {skipped}, failures {failures})",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
except TaskNoop:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"email_auto_translate action failed: {e}")
|
||||||
|
return str(e), False
|
||||||
|
|
||||||
|
|
||||||
_TYPE_COLORS = {
|
_TYPE_COLORS = {
|
||||||
"work": "#5b8abf", # blue
|
"work": "#5b8abf", # blue
|
||||||
"personal": "#a07ae0", # purple
|
"personal": "#a07ae0", # purple
|
||||||
@@ -693,6 +980,7 @@ async def action_classify_events(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
f"EVENTS: {_json.dumps(items)}"
|
f"EVENTS: {_json.dumps(items)}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
await wait_for_interactive_quiet("calendar classification action")
|
||||||
raw = await llm_call_async_with_fallback(
|
raw = await llm_call_async_with_fallback(
|
||||||
llm_candidates,
|
llm_candidates,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
@@ -761,19 +1049,44 @@ async def action_extract_email_events(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
import asyncio as _aio
|
import asyncio as _aio
|
||||||
try:
|
try:
|
||||||
from routes.email_pollers import _run_auto_summarize_once
|
from routes.email_pollers import _run_auto_summarize_once
|
||||||
|
account_id = _email_task_account_id(kwargs)
|
||||||
|
attempts = [
|
||||||
|
("3d window, 3 emails", 3, 3, 240),
|
||||||
|
("3d window, 2 emails", 3, 2, 150),
|
||||||
|
("1d window, 1 email", 1, 1, 90),
|
||||||
|
]
|
||||||
|
timed_out = []
|
||||||
|
last_result = ""
|
||||||
|
for label, days_back, max_process, timeout in attempts:
|
||||||
try:
|
try:
|
||||||
# Hard wall-clock budget: 5 min total. Per-LLM call already has its own timeout.
|
|
||||||
result = await _aio.wait_for(
|
result = await _aio.wait_for(
|
||||||
_run_auto_summarize_once(
|
_run_auto_summarize_once(
|
||||||
do_summary=False, do_reply=False, do_calendar=True, days_back=3,
|
do_summary=False,
|
||||||
|
do_reply=False,
|
||||||
|
do_calendar=True,
|
||||||
|
days_back=days_back,
|
||||||
|
account_id=account_id,
|
||||||
|
max_process=max_process,
|
||||||
),
|
),
|
||||||
timeout=300,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
if not _result_has_work(result):
|
last_result = result or ""
|
||||||
raise TaskNoop(f"email→calendar: {result or 'no new emails'}")
|
if _result_is_config_error(result):
|
||||||
return f"{result} (3d window)", True
|
return f"{result} ({label})", False
|
||||||
|
if _result_has_work(result):
|
||||||
|
suffix = f"{label}" if not timed_out else f"{label}; retried after timeout"
|
||||||
|
return f"{result} ({suffix})", True
|
||||||
|
raise TaskNoop(f"email→calendar: {result or 'no new emails'} ({label})")
|
||||||
except _aio.TimeoutError:
|
except _aio.TimeoutError:
|
||||||
return "Email→calendar pass exceeded 5 min budget — try fewer emails or a faster model", False
|
timed_out.append(label)
|
||||||
|
logger.warning(f"email calendar extraction timed out for {label}; retrying smaller batch")
|
||||||
|
continue
|
||||||
|
if timed_out:
|
||||||
|
raise TaskNoop(
|
||||||
|
"email→calendar: calendar extraction timed out on smaller batches; "
|
||||||
|
"will retry on the next scheduled run"
|
||||||
|
)
|
||||||
|
raise TaskNoop(f"email→calendar: {last_result or 'no new emails'}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"extract_email_events action failed: {e}")
|
logger.error(f"extract_email_events action failed: {e}")
|
||||||
return str(e), False
|
return str(e), False
|
||||||
@@ -942,6 +1255,7 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
await wait_for_interactive_quiet("sender signature action")
|
||||||
raw = await llm_call_async_with_fallback(
|
raw = await llm_call_async_with_fallback(
|
||||||
candidates,
|
candidates,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
@@ -1499,13 +1813,15 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
AGE_CUTOFF = _dt.utcnow() - _td(days=7)
|
AGE_CUTOFF = _dt.utcnow() - _td(days=7)
|
||||||
TRIAGE_VERSION = 3
|
TRIAGE_VERSION = 10
|
||||||
CATEGORY_TAGS = {
|
CATEGORY_TAGS = {
|
||||||
"newsletter", "marketing", "notification", "finance", "bills",
|
"bills", "receipt", "travel", "calendar", "action-needed",
|
||||||
"receipt", "travel", "security", "shopping", "social", "work",
|
}
|
||||||
"personal", "calendar",
|
VISIBLE_EMAIL_TAGS = CATEGORY_TAGS | {"urgent", "reply-soon"}
|
||||||
|
MANAGED_TAGS = VISIBLE_EMAIL_TAGS | {
|
||||||
|
"newsletter", "marketing", "notification", "finance", "security",
|
||||||
|
"shopping", "social", "work", "personal", "legal", "support", "promo",
|
||||||
}
|
}
|
||||||
MANAGED_TAGS = CATEGORY_TAGS | {"urgent", "reply-soon", "promo"}
|
|
||||||
|
|
||||||
# ── 1. Resolve LLM candidates (utility primary + utility fallbacks; fall
|
# ── 1. Resolve LLM candidates (utility primary + utility fallbacks; fall
|
||||||
# through to default chat as a last resort).
|
# through to default chat as a last resort).
|
||||||
@@ -1514,6 +1830,8 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
return "No LLM endpoint available", False
|
return "No LLM endpoint available", False
|
||||||
|
|
||||||
|
target_account_id = _email_task_account_id(kwargs)
|
||||||
|
|
||||||
# ── 2. Enumerate enabled accounts. Match this task's owner AND fall
|
# ── 2. Enumerate enabled accounts. Match this task's owner AND fall
|
||||||
# back to the legacy "unowned account whose imap_user / from_address
|
# back to the legacy "unowned account whose imap_user / from_address
|
||||||
# == this owner" pattern — same rule `_get_email_config` uses, so a
|
# == this owner" pattern — same rule `_get_email_config` uses, so a
|
||||||
@@ -1526,6 +1844,8 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
unowned = _or(_EA.owner == None, _EA.owner == "") # noqa: E711
|
unowned = _or(_EA.owner == None, _EA.owner == "") # noqa: E711
|
||||||
same_mailbox = _or(_EA.imap_user == owner, _EA.from_address == owner)
|
same_mailbox = _or(_EA.imap_user == owner, _EA.from_address == owner)
|
||||||
q = q.filter(_or(_EA.owner == owner, _and(unowned, same_mailbox)))
|
q = q.filter(_or(_EA.owner == owner, _and(unowned, same_mailbox)))
|
||||||
|
if target_account_id:
|
||||||
|
q = q.filter(_EA.id == target_account_id)
|
||||||
accounts = q.all()
|
accounts = q.all()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -1534,12 +1854,95 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
|
|
||||||
urgency_prompt = settings.get("urgent_email_prompt", "")
|
urgency_prompt = settings.get("urgent_email_prompt", "")
|
||||||
per_uid_scores = {} # key = "<acc_id>:<uid>" → {"score": 0-3, "reason": "..."}
|
per_uid_scores = {} # key = "<acc_id>:<uid>" → {"score": 0-3, "reason": "..."}
|
||||||
all_unread_keys = set() # for cache pruning
|
all_unread_keys = set()
|
||||||
llm_attempts = 0
|
llm_attempts = 0
|
||||||
saved_classifications = 0
|
saved_classifications = 0
|
||||||
failed_classifications = []
|
failed_classifications = []
|
||||||
|
tag_write_details = []
|
||||||
scanned = 0
|
scanned = 0
|
||||||
|
|
||||||
|
def _heuristic_email_verdict(item: dict) -> dict:
|
||||||
|
blob = (
|
||||||
|
f"{item.get('headers','')}\n{item.get('from','')}\n"
|
||||||
|
f"{item.get('subject','')}\n{item.get('body','')}"
|
||||||
|
).lower()
|
||||||
|
response_tags = []
|
||||||
|
type_candidates = []
|
||||||
|
|
||||||
|
def add_response(tag: str):
|
||||||
|
if tag in CATEGORY_TAGS and tag not in response_tags:
|
||||||
|
response_tags.append(tag)
|
||||||
|
|
||||||
|
def add_type(tag: str):
|
||||||
|
if tag in CATEGORY_TAGS and tag not in type_candidates:
|
||||||
|
type_candidates.append(tag)
|
||||||
|
|
||||||
|
bulkish = bool(_re.search(
|
||||||
|
r"\b(list-unsubscribe|list-id|mailchimp|mailchimpapp|view this email in your browser|unsubscribe|newsletter|digest|precedence:\s*bulk)\b",
|
||||||
|
blob,
|
||||||
|
))
|
||||||
|
marketingish = bool(_re.search(
|
||||||
|
r"\b(advertisement|sponsored|promo|promotion|sale|discount|offer|limited time|deal|coupon|shop now|buy now|membership|rewards?)\b",
|
||||||
|
blob,
|
||||||
|
))
|
||||||
|
if bulkish or marketingish:
|
||||||
|
add_type("newsletter")
|
||||||
|
if _re.search(r"\b(receipt|order|注文|payment confirmation|delivery|shipment|tracking|お届け|購入)\b", blob):
|
||||||
|
add_type("receipt")
|
||||||
|
if _re.search(r"\b(bill|billing|amount due|overdue|pay by|payment due|subscription could not be renewed)\b", blob):
|
||||||
|
add_type("bills")
|
||||||
|
if _re.search(r"\b(court|charge|legal|lawyer|solicitor|claim|judgment|registration fee|debt)\b", blob):
|
||||||
|
add_type("legal")
|
||||||
|
if _re.search(r"\b(flight|hotel|booking|reservation|itinerary|train|ticket|trip|旅|予約)\b", blob):
|
||||||
|
add_type("travel")
|
||||||
|
if _re.search(r"\b(ticket|case|support|helpdesk|request)\b", blob):
|
||||||
|
add_type("support")
|
||||||
|
if _re.search(r"\b(meeting|appointment|calendar|invite|event|schedule|予定|保育園|連絡帳)\b", blob):
|
||||||
|
add_response("calendar")
|
||||||
|
if _re.search(
|
||||||
|
r"\b(action required|required action|please reply|please respond|deadline|by \d{1,2} |pay within|submit|sign|confirm|approval|waiting outside|locked out|can't get in|cannot get in|invoice|bill|billing|payment|balance|debt|subscription|renewal|overdue|amount due|court|charge|legal|lawyer|solicitor|claim|judgment)\b",
|
||||||
|
blob,
|
||||||
|
):
|
||||||
|
add_response("action-needed")
|
||||||
|
|
||||||
|
type_priority = ("bills", "receipt", "travel")
|
||||||
|
tags = [*response_tags]
|
||||||
|
for type_tag in type_priority:
|
||||||
|
if type_tag in type_candidates and type_tag not in tags:
|
||||||
|
tags.append(type_tag)
|
||||||
|
if len(tags) >= len(response_tags) + 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
score = 0
|
||||||
|
reason = "categorized by email metadata"
|
||||||
|
if "action-needed" in response_tags:
|
||||||
|
score = 2
|
||||||
|
reason = "action likely needed"
|
||||||
|
if _re.search(r"\b(urgent|immediately|final notice|locked out|waiting outside|can't get in|cannot get in)\b", blob):
|
||||||
|
score = 3
|
||||||
|
reason = "urgent wording"
|
||||||
|
if (bulkish or marketingish) and score < 2:
|
||||||
|
score = 0
|
||||||
|
reason = "bulk marketing/newsletter"
|
||||||
|
|
||||||
|
_from_raw = item.get("from", "") or ""
|
||||||
|
if "<" in _from_raw:
|
||||||
|
_from_short = _from_raw.split("<", 1)[0].strip().strip('"') or _from_raw
|
||||||
|
else:
|
||||||
|
_from_short = _from_raw
|
||||||
|
return {
|
||||||
|
"score": max(0, min(3, score)),
|
||||||
|
"tags": tags[:4],
|
||||||
|
"spam": False,
|
||||||
|
"reason": reason,
|
||||||
|
"subject": (item.get("subject") or "")[:200],
|
||||||
|
"from": _from_short[:120],
|
||||||
|
"triage_version": TRIAGE_VERSION,
|
||||||
|
"message_id": (item.get("message_id") or "").strip(),
|
||||||
|
"unread": bool(item.get("unread")),
|
||||||
|
"ts": _time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
# ── 3. Per-account scan: pull headers + lightweight body for new UIDs
|
# ── 3. Per-account scan: pull headers + lightweight body for new UIDs
|
||||||
# since 7 days ago, score via LLM, cache the verdict.
|
# since 7 days ago, score via LLM, cache the verdict.
|
||||||
for acc in accounts:
|
for acc in accounts:
|
||||||
@@ -1555,13 +1958,13 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
conn = _imap_connect(account.id)
|
conn = _imap_connect(account.id)
|
||||||
try:
|
try:
|
||||||
conn.select("INBOX", readonly=True)
|
conn.select("INBOX", readonly=True)
|
||||||
# IMAP date is the only practical pre-filter — UNSEEN AND
|
# Tag recent inbox mail, not only unread mail. Urgency
|
||||||
# SINCE 7-days-ago. Date format is DD-Mon-YYYY.
|
# reminders below still only notify for unread messages.
|
||||||
since_str = AGE_CUTOFF.strftime("%d-%b-%Y")
|
since_str = AGE_CUTOFF.strftime("%d-%b-%Y")
|
||||||
status, data = conn.search(None, f'(UNSEEN SINCE {since_str})')
|
status, data = conn.uid("SEARCH", None, f'(SINCE {since_str})')
|
||||||
if status != "OK" or not data or not data[0]:
|
if status != "OK" or not data or not data[0]:
|
||||||
return results
|
return results
|
||||||
uids = data[0].split()
|
uids = data[0].split()[-30:]
|
||||||
for uid_b in uids:
|
for uid_b in uids:
|
||||||
uid = uid_b.decode() if isinstance(uid_b, bytes) else str(uid_b)
|
uid = uid_b.decode() if isinstance(uid_b, bytes) else str(uid_b)
|
||||||
key = f"{account.id}:{uid}"
|
key = f"{account.id}:{uid}"
|
||||||
@@ -1573,9 +1976,14 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
continue
|
continue
|
||||||
# Pull headers + first ~800 chars of plaintext body.
|
# Pull headers + first ~800 chars of plaintext body.
|
||||||
try:
|
try:
|
||||||
st, msg_data = conn.fetch(uid_b, "(RFC822.HEADER BODY.PEEK[TEXT]<0.800>)")
|
st, msg_data = conn.uid("FETCH", uid_b, "(UID FLAGS RFC822.HEADER BODY.PEEK[TEXT]<0.800>)")
|
||||||
if st != "OK" or not msg_data:
|
if st != "OK" or not msg_data:
|
||||||
continue
|
continue
|
||||||
|
flags_blob = b" ".join(
|
||||||
|
part[0] for part in msg_data
|
||||||
|
if isinstance(part, tuple) and part and isinstance(part[0], (bytes, bytearray))
|
||||||
|
)
|
||||||
|
is_unread = b"\\Seen" not in flags_blob
|
||||||
# Headers + body land in different tuples in the
|
# Headers + body land in different tuples in the
|
||||||
# response — concatenate the bytes for parsing.
|
# response — concatenate the bytes for parsing.
|
||||||
raw = b""
|
raw = b""
|
||||||
@@ -1635,6 +2043,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
"headers": header_blob,
|
"headers": header_blob,
|
||||||
"body": body_snippet.strip(),
|
"body": body_snippet.strip(),
|
||||||
"message_id": (msg.get("Message-ID") or "").strip(),
|
"message_id": (msg.get("Message-ID") or "").strip(),
|
||||||
|
"unread": is_unread,
|
||||||
})
|
})
|
||||||
except Exception as _fe:
|
except Exception as _fe:
|
||||||
logger.debug(f"urgency: header fetch for uid {uid} failed: {_fe}")
|
logger.debug(f"urgency: header fetch for uid {uid} failed: {_fe}")
|
||||||
@@ -1652,25 +2061,33 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
for item in items:
|
for item in items:
|
||||||
scanned += 1
|
scanned += 1
|
||||||
key = item["key"]
|
key = item["key"]
|
||||||
|
if item.get("unread"):
|
||||||
all_unread_keys.add(key)
|
all_unread_keys.add(key)
|
||||||
if item.get("cached"):
|
if item.get("cached"):
|
||||||
per_uid_scores[key] = item["cached"]
|
cached_v = dict(item["cached"])
|
||||||
|
cached_v["unread"] = bool(item.get("unread"))
|
||||||
|
per_uid_scores[key] = cached_v
|
||||||
continue
|
continue
|
||||||
# Skip uids we couldn't fetch (no subject/from/body).
|
# Skip uids we couldn't fetch (no subject/from/body).
|
||||||
if not item.get("subject") and not item.get("from"):
|
if not item.get("subject") and not item.get("from"):
|
||||||
continue
|
continue
|
||||||
|
verdict = _heuristic_email_verdict(item)
|
||||||
|
cache.setdefault("uids", {})[item["uid"]] = verdict
|
||||||
|
per_uid_scores[key] = verdict
|
||||||
|
saved_classifications += 1
|
||||||
|
continue
|
||||||
# ── LLM-classify. JSON-only response; bullet-proof parse.
|
# ── LLM-classify. JSON-only response; bullet-proof parse.
|
||||||
llm_attempts += 1
|
llm_attempts += 1
|
||||||
prompt = (
|
prompt = (
|
||||||
"You are triaging ONE unread email. Return ONLY JSON: "
|
"You are triaging ONE email. Return ONLY JSON: "
|
||||||
"{\"score\":0|1|2|3,\"tags\":[\"...\"],\"spam\":false,"
|
"{\"score\":0|1|2|3,\"tags\":[\"...\"],\"spam\":false,"
|
||||||
"\"reason\":\"one short phrase\"}.\n"
|
"\"reason\":\"one short phrase\"}.\n"
|
||||||
"0 = trivial / promotional · 1 = informational, no reply needed · "
|
"0 = trivial / promotional · 1 = informational, no reply needed · "
|
||||||
"2 = should reply within a day · 3 = urgent, reply now (deadline, blocker).\n\n"
|
"2 = should reply within a day · 3 = urgent, reply now (deadline, blocker).\n\n"
|
||||||
"Allowed tags: newsletter, marketing, notification, finance, bills, receipt, "
|
"Allowed visible tags: urgent, reply-soon, action-needed, calendar, bills, receipt, travel.\n"
|
||||||
"travel, security, shopping, social, work, personal, calendar.\n"
|
"Use action-needed when the user likely needs to reply, pay, sign, book, or decide. "
|
||||||
"Use marketing for ads, promos, sales, offers, and cold sales. Use newsletter "
|
"Use bills for bills or debts, receipt for purchases/deliveries, travel for reservations/trips, "
|
||||||
"for newsletters, digests, and recurring content. spam=true for scams, phishing, "
|
"and calendar only when a calendar event/reminder is involved. spam=true for scams, phishing, "
|
||||||
"junk, cold sales, generic ads, or no-personal-action bulk mail.\n"
|
"junk, cold sales, generic ads, or no-personal-action bulk mail.\n"
|
||||||
"Important: 'I'm outside', 'I am outside', 'waiting outside', 'at the door', "
|
"Important: 'I'm outside', 'I am outside', 'waiting outside', 'at the door', "
|
||||||
"'locked out', or 'can't get in' means score 3 unless clearly historical.\n\n"
|
"'locked out', or 'can't get in' means score 3 unless clearly historical.\n\n"
|
||||||
@@ -1679,6 +2096,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
f"Snippet:\n{item.get('body','')}\n"
|
f"Snippet:\n{item.get('body','')}\n"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
await wait_for_interactive_quiet("email urgency action")
|
||||||
raw = await llm_call_async_with_fallback(
|
raw = await llm_call_async_with_fallback(
|
||||||
candidates,
|
candidates,
|
||||||
[{"role": "user", "content": prompt}],
|
[{"role": "user", "content": prompt}],
|
||||||
@@ -1739,14 +2157,10 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
r"\b(advertisement|sponsored|promo|promotion|sale|discount|offer|limited time|deal|tickets?|tour|merch|stream|purchase|sold out|low tickets|coupon|shop now|buy now)\b",
|
r"\b(advertisement|sponsored|promo|promotion|sale|discount|offer|limited time|deal|tickets?|tour|merch|stream|purchase|sold out|low tickets|coupon|shop now|buy now)\b",
|
||||||
_blob,
|
_blob,
|
||||||
))
|
))
|
||||||
if "newsletter" not in tags and bulkish:
|
|
||||||
tags.append("newsletter")
|
|
||||||
if "marketing" not in tags and marketingish:
|
|
||||||
tags.append("marketing")
|
|
||||||
if (bulkish or marketingish) and score < 2:
|
if (bulkish or marketingish) and score < 2:
|
||||||
score = 0
|
score = 0
|
||||||
if not reason or "urgent" in reason.lower():
|
if not reason or "urgent" in reason.lower():
|
||||||
reason = "Bulk marketing/newsletter; no personal reply needed"
|
reason = "bulk mail; no personal reply needed"
|
||||||
# Strip "Name <addr>" to bare display name for compact summary.
|
# Strip "Name <addr>" to bare display name for compact summary.
|
||||||
_from_raw = item.get("from", "") or ""
|
_from_raw = item.get("from", "") or ""
|
||||||
if "<" in _from_raw:
|
if "<" in _from_raw:
|
||||||
@@ -1764,6 +2178,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
# Cache the message_id too so re-scans of already-cached
|
# Cache the message_id too so re-scans of already-cached
|
||||||
# UIDs can still write the inbox tag without re-LLM'ing.
|
# UIDs can still write the inbox tag without re-LLM'ing.
|
||||||
"message_id": (item.get("message_id") or "").strip(),
|
"message_id": (item.get("message_id") or "").strip(),
|
||||||
|
"unread": bool(item.get("unread")),
|
||||||
"ts": _time.time(),
|
"ts": _time.time(),
|
||||||
}
|
}
|
||||||
cache.setdefault("uids", {})[item["uid"]] = verdict
|
cache.setdefault("uids", {})[item["uid"]] = verdict
|
||||||
@@ -1778,9 +2193,9 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
logger.debug(f"urgency: LLM classify failed for {key}: {e}")
|
logger.debug(f"urgency: LLM classify failed for {key}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ── Prune cache entries for UIDs that are no longer unread (replied
|
# ── Prune cache entries for UIDs that are no longer in the recent
|
||||||
# / archived / deleted). Compare against `items` (everything UNSEEN
|
# scan window. Read messages remain cached because tags are useful
|
||||||
# in this scan window).
|
# on read mail too; unread state is refreshed per scan above.
|
||||||
seen_uids = {it["uid"] for it in items}
|
seen_uids = {it["uid"] for it in items}
|
||||||
cache_uids = cache.get("uids", {})
|
cache_uids = cache.get("uids", {})
|
||||||
for stale in [u for u in cache_uids if u not in seen_uids]:
|
for stale in [u for u in cache_uids if u not in seen_uids]:
|
||||||
@@ -1815,15 +2230,17 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
_tag = str(_tag).strip().lower().replace("_", "-")
|
_tag = str(_tag).strip().lower().replace("_", "-")
|
||||||
if _tag == "promo":
|
if _tag == "promo":
|
||||||
_tag = "marketing"
|
_tag = "marketing"
|
||||||
if _tag in CATEGORY_TAGS and _tag not in _new_tags:
|
if _tag == "action-needed" and any(t in _new_tags for t in ("urgent", "reply-soon")):
|
||||||
|
continue
|
||||||
|
if _tag in VISIBLE_EMAIL_TAGS and _tag not in _new_tags:
|
||||||
_new_tags.append(_tag)
|
_new_tags.append(_tag)
|
||||||
_spam = 1 if _v.get("spam") else 0
|
_spam = 1 if _v.get("spam") else 0
|
||||||
# _key is "<account_id>:<uid>" — extract uid for the row.
|
# _key is "<account_id>:<uid>" — extract uid for the row.
|
||||||
_uid_only = _key.split(":", 1)[-1]
|
_acc_id, _uid_only = (_key.split(":", 1) + [""])[:2]
|
||||||
_owner_key = owner or ""
|
_owner_key = owner or ""
|
||||||
_row = _conn.execute(
|
_row = _conn.execute(
|
||||||
"SELECT tags FROM email_tags WHERE message_id=? AND owner=?",
|
"SELECT tags FROM email_tags WHERE message_id=? AND owner=? AND account_id=?",
|
||||||
(_msg_id, _owner_key),
|
(_msg_id, _owner_key, _acc_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if _row:
|
if _row:
|
||||||
try:
|
try:
|
||||||
@@ -1842,23 +2259,42 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
for _tag in _new_tags:
|
for _tag in _new_tags:
|
||||||
if _tag not in _existing:
|
if _tag not in _existing:
|
||||||
_existing.append(_tag)
|
_existing.append(_tag)
|
||||||
|
if _new_tags or _spam:
|
||||||
|
tag_write_details.append({
|
||||||
|
"uid": _uid_only,
|
||||||
|
"subject": _v.get("subject", ""),
|
||||||
|
"from": _v.get("from", ""),
|
||||||
|
"tags": list(_new_tags),
|
||||||
|
"spam": _spam,
|
||||||
|
"reason": _v.get("reason", ""),
|
||||||
|
"updated": True,
|
||||||
|
})
|
||||||
_conn.execute(
|
_conn.execute(
|
||||||
"UPDATE email_tags SET tags=?, spam_verdict=?, spam_reason=?, uid=?, folder=?, subject=?, sender=? "
|
"UPDATE email_tags SET tags=?, spam_verdict=?, spam_reason=?, uid=?, folder=?, subject=?, sender=? "
|
||||||
"WHERE message_id=? AND owner=?",
|
"WHERE message_id=? AND owner=? AND account_id=?",
|
||||||
(_json.dumps(_existing), _spam, _v.get("reason", ""), _uid_only, "INBOX",
|
(_json.dumps(_existing), _spam, _v.get("reason", ""), _uid_only, "INBOX",
|
||||||
_v.get("subject", ""), _v.get("from", ""), _msg_id, _owner_key),
|
_v.get("subject", ""), _v.get("from", ""), _msg_id, _owner_key, _acc_id),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if not _new_tags and not _spam:
|
if not _new_tags and not _spam:
|
||||||
continue
|
continue
|
||||||
_conn.execute(
|
_conn.execute(
|
||||||
"INSERT INTO email_tags "
|
"INSERT INTO email_tags "
|
||||||
"(message_id, owner, uid, folder, subject, sender, tags, spam_verdict, spam_reason, created_at) "
|
"(message_id, owner, account_id, uid, folder, subject, sender, tags, spam_verdict, spam_reason, created_at) "
|
||||||
"VALUES (?, ?, ?, 'INBOX', ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, 'INBOX', ?, ?, ?, ?, ?, ?)",
|
||||||
(_msg_id, _owner_key, _uid_only, _v.get("subject", ""),
|
(_msg_id, _owner_key, _acc_id, _uid_only, _v.get("subject", ""),
|
||||||
_v.get("from", ""), _json.dumps(_new_tags), _spam, _v.get("reason", ""),
|
_v.get("from", ""), _json.dumps(_new_tags), _spam, _v.get("reason", ""),
|
||||||
_dt2.utcnow().isoformat()),
|
_dt2.utcnow().isoformat()),
|
||||||
)
|
)
|
||||||
|
tag_write_details.append({
|
||||||
|
"uid": _uid_only,
|
||||||
|
"subject": _v.get("subject", ""),
|
||||||
|
"from": _v.get("from", ""),
|
||||||
|
"tags": list(_new_tags),
|
||||||
|
"spam": _spam,
|
||||||
|
"reason": _v.get("reason", ""),
|
||||||
|
"updated": False,
|
||||||
|
})
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
finally:
|
finally:
|
||||||
_conn.close()
|
_conn.close()
|
||||||
@@ -1866,7 +2302,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
logger.warning(f"urgency: bulk tag write failed: {_te}")
|
logger.warning(f"urgency: bulk tag write failed: {_te}")
|
||||||
|
|
||||||
# ── 4. Aggregate state. urgent = score ≥ 2.
|
# ── 4. Aggregate state. urgent = score ≥ 2.
|
||||||
urgent_keys = [k for k, v in per_uid_scores.items() if v.get("score", 0) >= 2]
|
urgent_keys = [k for k, v in per_uid_scores.items() if v.get("score", 0) >= 2 and v.get("unread")]
|
||||||
max_score = max((v.get("score", 0) for v in per_uid_scores.values()), default=0)
|
max_score = max((v.get("score", 0) for v in per_uid_scores.values()), default=0)
|
||||||
total_urgent = len(urgent_keys)
|
total_urgent = len(urgent_keys)
|
||||||
|
|
||||||
@@ -1975,13 +2411,28 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
f"reply-soon {tier_counts[2]} · info {tier_counts[1]} · trivial {tier_counts[0]} · "
|
f"reply-soon {tier_counts[2]} · info {tier_counts[1]} · trivial {tier_counts[0]} · "
|
||||||
f"{saved_classifications} saved classifications"
|
f"{saved_classifications} saved classifications"
|
||||||
)
|
)
|
||||||
if llm_attempts != saved_classifications:
|
if failed_classifications:
|
||||||
head += f" · {llm_attempts - saved_classifications} failed"
|
head += f" · {len(failed_classifications)} failed"
|
||||||
if newly_notified:
|
if newly_notified:
|
||||||
head += f" · notified {len(newly_notified)}"
|
head += f" · notified {len(newly_notified)}"
|
||||||
if notify_failed:
|
if notify_failed:
|
||||||
head += f" · notify failed {len(notify_failed)}"
|
head += f" · notify failed {len(notify_failed)}"
|
||||||
|
|
||||||
|
def _fmt_tag_write(v):
|
||||||
|
subj = (v.get("subject") or "(no subject)")[:80]
|
||||||
|
frm = v.get("from") or ""
|
||||||
|
tags = list(v.get("tags") or [])
|
||||||
|
if v.get("spam"):
|
||||||
|
tags.append("spam")
|
||||||
|
tag_txt = ", ".join(tags) if tags else "cleared managed tags"
|
||||||
|
why = v.get("reason") or ""
|
||||||
|
op = "updated" if v.get("updated") else "created"
|
||||||
|
line = f"- **{subj}**" + (f" — _{frm}_" if frm else "")
|
||||||
|
line += f" — `{tag_txt}` ({op})"
|
||||||
|
if why:
|
||||||
|
line += f" · {why}"
|
||||||
|
return line
|
||||||
|
|
||||||
def _fmt_one(v, newly_notified_set, failed_set, key):
|
def _fmt_one(v, newly_notified_set, failed_set, key):
|
||||||
subj = (v.get("subject") or "(no subject)")[:80]
|
subj = (v.get("subject") or "(no subject)")[:80]
|
||||||
frm = v.get("from") or ""
|
frm = v.get("from") or ""
|
||||||
@@ -1997,6 +2448,13 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
for k, v in per_uid_scores.items():
|
for k, v in per_uid_scores.items():
|
||||||
by_tier.setdefault(v.get("score", 0), []).append((k, v))
|
by_tier.setdefault(v.get("score", 0), []).append((k, v))
|
||||||
lines = [head]
|
lines = [head]
|
||||||
|
if tag_write_details:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"**Applied tags ({len(tag_write_details)}):**")
|
||||||
|
for v in tag_write_details[:16]:
|
||||||
|
lines.append(_fmt_tag_write(v))
|
||||||
|
if len(tag_write_details) > 16:
|
||||||
|
lines.append(f"…and {len(tag_write_details) - 16} more")
|
||||||
tier_labels = {3: "Urgent", 2: "Reply soon", 1: "Informational", 0: "Trivial"}
|
tier_labels = {3: "Urgent", 2: "Reply soon", 1: "Informational", 0: "Trivial"}
|
||||||
for tier in (3, 2, 1, 0):
|
for tier in (3, 2, 1, 0):
|
||||||
items_t = by_tier.get(tier, [])
|
items_t = by_tier.get(tier, [])
|
||||||
@@ -2068,6 +2526,7 @@ async def action_cookbook_serve(
|
|||||||
end_after_min = int(cfg.get("end_after_min") or 0)
|
end_after_min = int(cfg.get("end_after_min") or 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
end_after_min = 0
|
end_after_min = 0
|
||||||
|
set_default = bool(cfg.get("set_default", True))
|
||||||
|
|
||||||
state_path = Path(COOKBOOK_STATE_FILE)
|
state_path = Path(COOKBOOK_STATE_FILE)
|
||||||
try:
|
try:
|
||||||
@@ -2154,6 +2613,51 @@ async def action_cookbook_serve(
|
|||||||
return f"Launch rejected: {data.get('error') or data.get('detail') or 'unknown'}", False
|
return f"Launch rejected: {data.get('error') or data.get('detail') or 'unknown'}", False
|
||||||
|
|
||||||
sid = data.get("session_id") or ""
|
sid = data.get("session_id") or ""
|
||||||
|
endpoint_id = data.get("endpoint_id") or ""
|
||||||
|
# Scheduled serves are usually meant to become the active local model for
|
||||||
|
# chat/tools while their time window is open. Persist both endpoint and
|
||||||
|
# model so task/utility/default resolution does not keep routing to a stale
|
||||||
|
# API fallback. Allow explicit opt-out with {"set_default": false}.
|
||||||
|
if endpoint_id and set_default:
|
||||||
|
try:
|
||||||
|
selected_model = repo_id
|
||||||
|
try:
|
||||||
|
from core.database import SessionLocal as _SL, ModelEndpoint as _ME
|
||||||
|
_db = _SL()
|
||||||
|
try:
|
||||||
|
_ep = _db.query(_ME).filter(_ME.id == endpoint_id).first()
|
||||||
|
if _ep and _ep.cached_models:
|
||||||
|
_models = json.loads(_ep.cached_models or "[]")
|
||||||
|
if isinstance(_models, list) and _models:
|
||||||
|
selected_model = str(_models[0])
|
||||||
|
finally:
|
||||||
|
_db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
from src.settings import load_settings as _load_settings, save_settings as _save_settings
|
||||||
|
_settings = _load_settings()
|
||||||
|
_settings["default_endpoint_id"] = endpoint_id
|
||||||
|
_settings["default_model"] = selected_model
|
||||||
|
# Keep background tasks aligned unless the user explicitly chose a
|
||||||
|
# separate task model.
|
||||||
|
if not (_settings.get("task_endpoint_id") or "").strip():
|
||||||
|
_settings["task_endpoint_id"] = endpoint_id
|
||||||
|
_settings["task_model"] = selected_model
|
||||||
|
if not (_settings.get("utility_endpoint_id") or "").strip():
|
||||||
|
_settings["utility_endpoint_id"] = endpoint_id
|
||||||
|
_settings["utility_model"] = selected_model
|
||||||
|
_save_settings(_settings)
|
||||||
|
if owner:
|
||||||
|
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||||
|
_prefs = _load_for_user(owner)
|
||||||
|
_prefs["default_endpoint_id"] = endpoint_id
|
||||||
|
_prefs["default_model"] = selected_model
|
||||||
|
if not (_prefs.get("utility_endpoint_id") or "").strip():
|
||||||
|
_prefs["utility_endpoint_id"] = endpoint_id
|
||||||
|
_prefs["utility_model"] = selected_model
|
||||||
|
_save_for_user(owner, _prefs)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"cookbook_serve: default endpoint update failed: {e}")
|
||||||
# Register the new task in cookbook_state.json + stamp it with our
|
# Register the new task in cookbook_state.json + stamp it with our
|
||||||
# scheduler-owner markers. /api/model/serve spawns the tmux session
|
# scheduler-owner markers. /api/model/serve spawns the tmux session
|
||||||
# but leaves the state-write to the UI — when a scheduled action
|
# but leaves the state-write to the UI — when a scheduled action
|
||||||
@@ -2197,12 +2701,16 @@ async def action_cookbook_serve(
|
|||||||
"sshPort": ssh_port or "",
|
"sshPort": ssh_port or "",
|
||||||
"platform": platform or "linux",
|
"platform": platform or "linux",
|
||||||
"_serveReady": False,
|
"_serveReady": False,
|
||||||
"_endpointAdded": False,
|
"_endpointAdded": bool(endpoint_id),
|
||||||
}
|
}
|
||||||
tasks.append(existing)
|
tasks.append(existing)
|
||||||
# Stamp ownership + end-at on the task entry.
|
# Stamp ownership + end-at on the task entry.
|
||||||
existing["_scheduledByTask"] = task_name or ""
|
existing["_scheduledByTask"] = task_name or ""
|
||||||
existing["_scheduledByOwner"] = owner or ""
|
existing["_scheduledByOwner"] = owner or ""
|
||||||
|
if endpoint_id:
|
||||||
|
existing["_endpointId"] = endpoint_id
|
||||||
|
existing["endpointId"] = endpoint_id
|
||||||
|
existing["_endpointAdded"] = True
|
||||||
if end_after_min > 0:
|
if end_after_min > 0:
|
||||||
existing["_scheduledStopAtMs"] = int(_time.time() * 1000) + end_after_min * 60 * 1000
|
existing["_scheduledStopAtMs"] = int(_time.time() * 1000) + end_after_min * 60 * 1000
|
||||||
fresh["tasks"] = tasks
|
fresh["tasks"] = tasks
|
||||||
@@ -2230,6 +2738,7 @@ BUILTIN_ACTIONS = {
|
|||||||
"tidy_research": action_tidy_research,
|
"tidy_research": action_tidy_research,
|
||||||
"summarize_emails": action_summarize_emails,
|
"summarize_emails": action_summarize_emails,
|
||||||
"draft_email_replies": action_draft_email_replies,
|
"draft_email_replies": action_draft_email_replies,
|
||||||
|
"email_auto_translate": action_email_auto_translate,
|
||||||
"extract_email_events": action_extract_email_events,
|
"extract_email_events": action_extract_email_events,
|
||||||
"classify_events": action_classify_events,
|
"classify_events": action_classify_events,
|
||||||
# ping_events removed from the user-facing registry. Calendar reminders
|
# ping_events removed from the user-facing registry. Calendar reminders
|
||||||
@@ -2254,6 +2763,7 @@ BUILTIN_ACTION_INFO = {
|
|||||||
"tidy_research": "Remove orphaned research files (sessions that were deleted)",
|
"tidy_research": "Remove orphaned research files (sessions that were deleted)",
|
||||||
"summarize_emails": "Pre-generate AI summaries for new inbox emails",
|
"summarize_emails": "Pre-generate AI summaries for new inbox emails",
|
||||||
"draft_email_replies": "Pre-draft AI reply suggestions for new inbox emails",
|
"draft_email_replies": "Pre-draft AI reply suggestions for new inbox emails",
|
||||||
|
"email_auto_translate": "Detect foreign-language emails and cache translated text for the email reader",
|
||||||
"extract_email_events": "Scan emails for booking/meeting confirmations and auto-add to calendar",
|
"extract_email_events": "Scan emails for booking/meeting confirmations and auto-add to calendar",
|
||||||
"classify_events": "Tag upcoming events with importance (low/normal/high/critical) and type (work/health/travel/etc.); colors them too",
|
"classify_events": "Tag upcoming events with importance (low/normal/high/critical) and type (work/health/travel/etc.); colors them too",
|
||||||
"daily_brief": "Build a morning digest: today's calendar, unread email count + top senders, active todos",
|
"daily_brief": "Build a morning digest: today's calendar, unread email count + top senders, active todos",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Design notes:
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
@@ -500,6 +501,7 @@ def _event_payload(ev) -> dict:
|
|||||||
"all_day": ev.all_day,
|
"all_day": ev.all_day,
|
||||||
"is_utc": ev.is_utc,
|
"is_utc": ev.is_utc,
|
||||||
"rrule": ev.rrule or "",
|
"rrule": ev.rrule or "",
|
||||||
|
"recurrence_exdates": json.loads(ev.recurrence_exdates or "[]") if getattr(ev, "recurrence_exdates", "") else [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -33,7 +33,8 @@ def build_event_ical(ev: dict) -> str:
|
|||||||
"""Serialize a local event dict to a VCALENDAR/VEVENT iCalendar string.
|
"""Serialize a local event dict to a VCALENDAR/VEVENT iCalendar string.
|
||||||
|
|
||||||
``ev`` keys: uid, summary, description, location, dtstart (datetime),
|
``ev`` keys: uid, summary, description, location, dtstart (datetime),
|
||||||
dtend (datetime), all_day (bool), is_utc (bool), rrule (str).
|
dtend (datetime), all_day (bool), is_utc (bool), rrule (str),
|
||||||
|
recurrence_exdates (list[str]).
|
||||||
Mirrors how the pull path interprets is_utc/all_day so a round-trip is stable.
|
Mirrors how the pull path interprets is_utc/all_day so a round-trip is stable.
|
||||||
"""
|
"""
|
||||||
from icalendar import Calendar, Event as iEvent
|
from icalendar import Calendar, Event as iEvent
|
||||||
@@ -70,6 +71,15 @@ def build_event_ical(ev: dict) -> str:
|
|||||||
ve.add("rrule", vRecur.from_ical(ev["rrule"]))
|
ve.add("rrule", vRecur.from_ical(ev["rrule"]))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("CalDAV write-back: skipping unparseable rrule %r", ev.get("rrule"))
|
logger.debug("CalDAV write-back: skipping unparseable rrule %r", ev.get("rrule"))
|
||||||
|
for exdate in ev.get("recurrence_exdates") or []:
|
||||||
|
try:
|
||||||
|
if ev.get("all_day"):
|
||||||
|
ve.add("exdate", datetime.strptime(exdate[:10], "%Y-%m-%d").date())
|
||||||
|
else:
|
||||||
|
dt = datetime.strptime(exdate[:16], "%Y-%m-%dT%H:%M")
|
||||||
|
ve.add("exdate", dt.replace(tzinfo=timezone.utc) if ev.get("is_utc") else dt)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("CalDAV write-back: skipping unparseable exdate %r", exdate)
|
||||||
|
|
||||||
cal.add_component(ve)
|
cal.add_component(ve)
|
||||||
return cal.to_ical().decode("utf-8")
|
return cal.to_ical().decode("utf-8")
|
||||||
|
|||||||
@@ -29,6 +29,34 @@ from src.youtube_handler import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_upload_vision_to_gallery(file_info: Dict[str, Any], owner: Optional[str], text: str) -> None:
|
||||||
|
file_hash = (file_info or {}).get("hash")
|
||||||
|
if not file_hash or not text:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from core.database import GalleryImage, SessionLocal
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
q = db.query(GalleryImage).filter(
|
||||||
|
GalleryImage.file_hash == file_hash,
|
||||||
|
GalleryImage.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
if owner:
|
||||||
|
q = q.filter(GalleryImage.owner == owner)
|
||||||
|
img = q.first()
|
||||||
|
if not img:
|
||||||
|
return
|
||||||
|
img.caption = text.strip()
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to sync upload vision text to gallery: %s", e)
|
||||||
|
|
||||||
|
|
||||||
class ChatHandler:
|
class ChatHandler:
|
||||||
"""Handles chat operations for both streaming and non-streaming endpoints."""
|
"""Handles chat operations for both streaming and non-streaming endpoints."""
|
||||||
|
|
||||||
@@ -207,6 +235,7 @@ class ChatHandler:
|
|||||||
_vtext = _vf.read().strip()
|
_vtext = _vf.read().strip()
|
||||||
if _vtext:
|
if _vtext:
|
||||||
enhanced_message += f"\n[User-corrected caption / OCR for this image — treat as authoritative]:\n{_vtext}"
|
enhanced_message += f"\n[User-corrected caption / OCR for this image — treat as authoritative]:\n{_vtext}"
|
||||||
|
_sync_upload_vision_to_gallery(file_info, owner, _vtext)
|
||||||
_m = meta_by_id.get(att_id)
|
_m = meta_by_id.get(att_id)
|
||||||
if _m is not None:
|
if _m is not None:
|
||||||
_m["vision"] = _vtext
|
_m["vision"] = _vtext
|
||||||
@@ -226,6 +255,7 @@ class ChatHandler:
|
|||||||
cached_desc = _vf.read().strip()
|
cached_desc = _vf.read().strip()
|
||||||
if cached_desc and not cached_desc.startswith("["):
|
if cached_desc and not cached_desc.startswith("["):
|
||||||
vl_desc = cached_desc
|
vl_desc = cached_desc
|
||||||
|
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
||||||
except Exception:
|
except Exception:
|
||||||
vl_desc = None
|
vl_desc = None
|
||||||
if not vl_desc:
|
if not vl_desc:
|
||||||
@@ -237,6 +267,7 @@ class ChatHandler:
|
|||||||
os.makedirs(os.path.join(UPLOAD_DIR, ".vision"), exist_ok=True)
|
os.makedirs(os.path.join(UPLOAD_DIR, ".vision"), exist_ok=True)
|
||||||
with open(_vcache, "w", encoding="utf-8") as _vf:
|
with open(_vcache, "w", encoding="utf-8") as _vf:
|
||||||
_vf.write(vl_desc)
|
_vf.write(vl_desc)
|
||||||
|
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}"
|
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}"
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import os
|
|||||||
|
|
||||||
from src.runtime_paths import get_app_root, get_default_data_dir
|
from src.runtime_paths import get_app_root, get_default_data_dir
|
||||||
|
|
||||||
APP_VERSION = "1.0.1"
|
APP_VERSION = "1.0.2"
|
||||||
|
|
||||||
# Base paths
|
# Base paths
|
||||||
BASE_DIR = os.path.join(get_app_root(), "")
|
BASE_DIR = os.path.join(get_app_root(), "")
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ async def _delete_endpoint_for_task(task: dict) -> None:
|
|||||||
the picker (probe goes offline; chats still try to route there) and
|
the picker (probe goes offline; chats still try to route there) and
|
||||||
the user has to delete it by hand in Settings -> Endpoints.
|
the user has to delete it by hand in Settings -> Endpoints.
|
||||||
"""
|
"""
|
||||||
|
endpoint_id = (task.get("_endpointId") or task.get("endpointId") or "").strip()
|
||||||
|
if not endpoint_id:
|
||||||
|
logger.info(
|
||||||
|
"cookbook_serve_lifecycle: task %s has no endpoint id; skipping endpoint deletion",
|
||||||
|
task.get("sessionId") or task.get("id") or "",
|
||||||
|
)
|
||||||
|
return
|
||||||
import re as _re
|
import re as _re
|
||||||
payload = task.get("payload") or {}
|
payload = task.get("payload") or {}
|
||||||
cmd = str(payload.get("_cmd") or "")
|
cmd = str(payload.get("_cmd") or "")
|
||||||
@@ -66,13 +73,10 @@ async def _delete_endpoint_for_task(task: dict) -> None:
|
|||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
return
|
return
|
||||||
eps = r.json() if r.content else []
|
eps = r.json() if r.content else []
|
||||||
# Prefer exact URL match; fall back to host:port substring so we
|
# Delete only the endpoint created by this scheduled serve. URL
|
||||||
# still catch the case where 0.0.0.0 vs the registered host
|
# matching is unsafe because a later scheduled serve can reuse the
|
||||||
# representation diverged.
|
# same host:port after an older task has gone stale.
|
||||||
ep = next((e for e in eps if e.get("base_url") == base_url), None)
|
ep = next((e for e in eps if e.get("id") == endpoint_id), None)
|
||||||
if not ep:
|
|
||||||
hostport = f"{host}:{port}"
|
|
||||||
ep = next((e for e in eps if hostport in (e.get("base_url") or "")), None)
|
|
||||||
if ep:
|
if ep:
|
||||||
await client.delete(
|
await client.delete(
|
||||||
f"{internal_api_base()}/api/model-endpoints/{ep['id']}",
|
f"{internal_api_base()}/api/model-endpoints/{ep['id']}",
|
||||||
|
|||||||
+19
-1
@@ -6,7 +6,7 @@ Reusable document actions callable from both REST routes and the task scheduler.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -77,10 +77,28 @@ async def run_document_tidy(owner: str) -> str:
|
|||||||
deleted = 0
|
deleted = 0
|
||||||
kept = 0
|
kept = 0
|
||||||
survivors = [] # docs that pass the junk rules, considered for dedup
|
survivors = [] # docs that pass the junk rules, considered for dedup
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
|
created = doc.created_at
|
||||||
|
if created and created.tzinfo is None:
|
||||||
|
created = created.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Skip freshly created documents to avoid deleting them while the user is actively editing
|
||||||
|
if created and (now - created).total_seconds() < 900: # 15 minutes
|
||||||
|
survivors.append(doc)
|
||||||
|
continue
|
||||||
|
|
||||||
content = (doc.current_content or "").strip()
|
content = (doc.current_content or "").strip()
|
||||||
title = (doc.title or "").strip().lower()
|
title = (doc.title or "").strip().lower()
|
||||||
|
is_fresh_empty = (
|
||||||
|
not content
|
||||||
|
and created is not None
|
||||||
|
and (now - created).total_seconds() < 1800
|
||||||
|
)
|
||||||
|
if is_fresh_empty:
|
||||||
|
survivors.append(doc)
|
||||||
|
continue
|
||||||
|
|
||||||
# Strip markdown noise to get "real" character count
|
# Strip markdown noise to get "real" character count
|
||||||
stripped = re.sub(r"^#{1,6}\s+", "", content, flags=re.MULTILINE) # headers
|
stripped = re.sub(r"^#{1,6}\s+", "", content, flags=re.MULTILINE) # headers
|
||||||
|
|||||||
@@ -47,6 +47,33 @@ def _endpoint_cached_models(ep) -> list:
|
|||||||
return models if isinstance(models, list) else []
|
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:
|
def _endpoint_hidden_models(ep) -> set:
|
||||||
"""Model ids the admin disabled on this endpoint (the UI's hidden list)."""
|
"""Model ids the admin disabled on this endpoint (the UI's hidden list)."""
|
||||||
raw = getattr(ep, "hidden_models", None)
|
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").
|
raw first one resolves to a model that 400s ("requires terms acceptance").
|
||||||
"""
|
"""
|
||||||
hidden = _endpoint_hidden_models(ep)
|
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]]:
|
def resolve_endpoint_runtime(ep, owner: Optional[str] = None) -> Tuple[str, Optional[str]]:
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Policy checks for explicit host Docker access from a container."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
HOST_DOCKER_ENV_VAR = "ODYSSEUS_ENABLE_HOST_DOCKER"
|
||||||
|
HOST_DOCKER_SOCKET_PATH = "/var/run/docker.sock"
|
||||||
|
|
||||||
|
HOST_DOCKER_ACCESS_HINT = (
|
||||||
|
"Local Docker daemon access is disabled inside the Odysseus container; a "
|
||||||
|
"Docker CLI alone is not enough. Default Docker Compose intentionally does "
|
||||||
|
"not mount the host Docker socket. Raw socket access is high-trust and can "
|
||||||
|
"grant broad control over the host Docker daemon. If you accept that risk, "
|
||||||
|
"enable docker/host-docker.yml. Remote server Docker workflows over SSH "
|
||||||
|
"remain preferred."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def running_in_container(
|
||||||
|
dockerenv_path: str = "/.dockerenv",
|
||||||
|
cgroup_path: str = "/proc/1/cgroup",
|
||||||
|
) -> bool:
|
||||||
|
if os.path.exists(dockerenv_path):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
with open(cgroup_path, "r", encoding="utf-8") as handle:
|
||||||
|
contents = handle.read()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return any(token in contents for token in ("docker", "containerd", "kubepods"))
|
||||||
|
|
||||||
|
|
||||||
|
def host_docker_access_enabled(
|
||||||
|
socket_path: str = HOST_DOCKER_SOCKET_PATH,
|
||||||
|
*,
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
env = os.environ if environ is None else environ
|
||||||
|
if env.get(HOST_DOCKER_ENV_VAR, "").strip().lower() != "true":
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mode = os.stat(socket_path).st_mode
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return stat.S_ISSOCK(mode)
|
||||||
|
|
||||||
|
|
||||||
|
def local_docker_available(
|
||||||
|
*,
|
||||||
|
cli_available: bool,
|
||||||
|
in_container: bool | None = None,
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
socket_path: str = HOST_DOCKER_SOCKET_PATH,
|
||||||
|
) -> bool:
|
||||||
|
if not cli_available:
|
||||||
|
return False
|
||||||
|
containerized = running_in_container() if in_container is None else in_container
|
||||||
|
if not containerized:
|
||||||
|
return True
|
||||||
|
return host_docker_access_enabled(socket_path, environ=environ)
|
||||||
+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:
|
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]]:
|
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}
|
return {"error": "Path must not contain a fragment", "exit_code": 1}
|
||||||
|
|
||||||
url = _join_integration_url(base_url, path)
|
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()
|
method = method.upper()
|
||||||
|
|
||||||
# Build headers
|
# Build headers
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Foreground activity gate for background work.
|
||||||
|
|
||||||
|
Background tasks are allowed to run only after normal UI/API traffic has
|
||||||
|
settled. This keeps scheduled jobs and email pollers from competing with the
|
||||||
|
user opening Odysseus, Cookbook, email, documents, notes, or other panels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
_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:
|
||||||
|
return os.getenv("BACKGROUND_TASK_FOREGROUND_GATE", "true").lower() not in {"0", "false", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
|
def _quiet_seconds() -> float:
|
||||||
|
try:
|
||||||
|
return max(0.0, float(os.getenv("BACKGROUND_TASK_QUIET_MS", "1500")) / 1000.0)
|
||||||
|
except Exception:
|
||||||
|
return 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def _max_wait_seconds() -> float:
|
||||||
|
"""0 means wait indefinitely until the UI is quiet."""
|
||||||
|
try:
|
||||||
|
return max(0.0, float(os.getenv("BACKGROUND_TASK_MAX_WAIT_SECONDS", "0")))
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _browser_active_seconds() -> float:
|
||||||
|
"""How long a visible Odysseus browser heartbeat blocks background tasks."""
|
||||||
|
try:
|
||||||
|
return max(0.0, float(os.getenv("BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS", "45")))
|
||||||
|
except Exception:
|
||||||
|
return 45.0
|
||||||
|
|
||||||
|
|
||||||
|
def _condition() -> asyncio.Condition:
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PASSIVE_PREFIXES = (
|
||||||
|
"/api/chat/stream_status",
|
||||||
|
"/api/health",
|
||||||
|
"/api/prefs",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def should_track_interactive_request(path: str, method: str = "GET") -> bool:
|
||||||
|
if not _enabled():
|
||||||
|
return False
|
||||||
|
if (method or "").upper() == "OPTIONS":
|
||||||
|
return False
|
||||||
|
if path in _PASSIVE_EXACT_PATHS:
|
||||||
|
return False
|
||||||
|
if any(path.startswith(prefix) for prefix in _PASSIVE_PREFIXES):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_browser_activity() -> None:
|
||||||
|
"""Record that an authenticated browser tab is visibly using Odysseus."""
|
||||||
|
global _LAST_BROWSER_ACTIVITY
|
||||||
|
if not _enabled():
|
||||||
|
return
|
||||||
|
cond = _condition()
|
||||||
|
async with cond:
|
||||||
|
_LAST_BROWSER_ACTIVITY = time.monotonic()
|
||||||
|
cond.notify_all()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_recent_browser_activity(now: float | None = None) -> bool:
|
||||||
|
ttl = _browser_active_seconds()
|
||||||
|
if ttl <= 0 or _LAST_BROWSER_ACTIVITY <= 0:
|
||||||
|
return False
|
||||||
|
return ((now if now is not None else time.monotonic()) - _LAST_BROWSER_ACTIVITY) < ttl
|
||||||
|
|
||||||
|
|
||||||
|
def has_foreground_activity(now: float | None = None) -> bool:
|
||||||
|
"""Return True when foreground browser/model work should stop background jobs.
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_active_chat_stream() -> bool:
|
||||||
|
"""Best-effort check for foreground model work that outlives HTTP requests.
|
||||||
|
|
||||||
|
Chat/agent streams are detached from the browser SSE so a stream can keep
|
||||||
|
running after the request that started it has returned. Background LLM
|
||||||
|
tasks must still wait for those runs; otherwise helpers like email
|
||||||
|
auto-translate compete with the user's active chat on the same local model.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from routes import chat_routes as _chat_routes
|
||||||
|
active_streams = getattr(_chat_routes, "_active_streams", {}) or {}
|
||||||
|
if active_streams:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from src import agent_runs
|
||||||
|
runs = getattr(agent_runs, "_RUNS", {}) or {}
|
||||||
|
return any(getattr(run, "status", None) == "running" for run in runs.values())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def track_interactive_request(path: str = "", method: str = ""):
|
||||||
|
global _ACTIVE_REQUESTS, _LAST_ACTIVITY
|
||||||
|
if not _enabled():
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
cond = _condition()
|
||||||
|
async with cond:
|
||||||
|
_ACTIVE_REQUESTS += 1
|
||||||
|
_LAST_ACTIVITY = time.monotonic()
|
||||||
|
cond.notify_all()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
async with cond:
|
||||||
|
_ACTIVE_REQUESTS = max(0, _ACTIVE_REQUESTS - 1)
|
||||||
|
_LAST_ACTIVITY = time.monotonic()
|
||||||
|
cond.notify_all()
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_interactive_quiet(label: str = "") -> bool:
|
||||||
|
"""Wait until foreground requests have stopped for the configured window.
|
||||||
|
|
||||||
|
Returns True if the caller had to wait at all. The label is intentionally
|
||||||
|
only for future logging/debugging so callers can keep their code simple.
|
||||||
|
"""
|
||||||
|
if not _enabled():
|
||||||
|
return False
|
||||||
|
|
||||||
|
quiet = _quiet_seconds()
|
||||||
|
max_wait = _max_wait_seconds()
|
||||||
|
deadline = time.monotonic() + max_wait if max_wait > 0 else None
|
||||||
|
cond = _condition()
|
||||||
|
waited = False
|
||||||
|
|
||||||
|
while True:
|
||||||
|
async with cond:
|
||||||
|
now = time.monotonic()
|
||||||
|
quiet_remaining = quiet - (now - _LAST_ACTIVITY)
|
||||||
|
active_stream = _has_active_chat_stream()
|
||||||
|
browser_active = _has_recent_browser_activity(now)
|
||||||
|
if _ACTIVE_REQUESTS <= 0 and quiet_remaining <= 0 and not active_stream and not browser_active:
|
||||||
|
return waited
|
||||||
|
|
||||||
|
waited = True
|
||||||
|
timeout = 0.25 if (_ACTIVE_REQUESTS > 0 or active_stream or browser_active) else min(max(quiet_remaining, 0.05), 0.5)
|
||||||
|
if deadline is not None:
|
||||||
|
remaining = deadline - now
|
||||||
|
if remaining <= 0:
|
||||||
|
return waited
|
||||||
|
timeout = min(timeout, remaining)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(cond.wait(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
+264
-2
@@ -8,13 +8,88 @@ import hashlib
|
|||||||
import threading
|
import threading
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from typing import Optional, Dict, List, Tuple
|
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
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class LLMConfig:
|
||||||
"""Configuration constants for LLM operations."""
|
"""Configuration constants for LLM operations."""
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
@@ -110,6 +185,18 @@ _HARMONY_MARKERS = (
|
|||||||
)
|
)
|
||||||
_HARMONY_MAX_MARKER_LEN = max(len(marker) for marker in _HARMONY_MARKERS)
|
_HARMONY_MAX_MARKER_LEN = max(len(marker) for marker in _HARMONY_MARKERS)
|
||||||
|
|
||||||
|
_VISIBLE_CHAT_TEMPLATE_ARTIFACT_RE = re.compile(
|
||||||
|
r"(?:\|end\|)+\|?assistan(?:t)?\|?"
|
||||||
|
r"|\|assistan(?:t)?\|"
|
||||||
|
r"|<\|im_start\|>\s*assistant"
|
||||||
|
r"|<\|im_end\|>",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_visible_chat_template_artifacts(text: str) -> str:
|
||||||
|
return _VISIBLE_CHAT_TEMPLATE_ARTIFACT_RE.sub("", text or "")
|
||||||
|
|
||||||
|
|
||||||
def _harmony_suffix_hold_len(text: str) -> int:
|
def _harmony_suffix_hold_len(text: str) -> int:
|
||||||
"""Return how many trailing chars could be the start of a harmony marker."""
|
"""Return how many trailing chars could be the start of a harmony marker."""
|
||||||
@@ -187,6 +274,75 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
|
|||||||
payload["thinking"] = True
|
payload["thinking"] = True
|
||||||
return f"data: {json.dumps(payload)}\n\n"
|
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:
|
def _model_activity_key(url: str, model: str) -> str:
|
||||||
return f"{(url or '').strip()}|{(model or '').strip()}"
|
return f"{(url or '').strip()}|{(model or '').strip()}"
|
||||||
|
|
||||||
@@ -743,6 +899,52 @@ def _apply_local_cache_affinity(payload: Dict, url: str, session_id: Optional[st
|
|||||||
payload.setdefault("cache_prompt", True)
|
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]:
|
def _provider_headers(provider: str, headers: Optional[Dict] = None) -> Dict[str, str]:
|
||||||
h = {"Content-Type": "application/json"}
|
h = {"Content-Type": "application/json"}
|
||||||
if isinstance(headers, dict):
|
if isinstance(headers, dict):
|
||||||
@@ -1373,6 +1575,7 @@ def _sanitize_llm_messages(messages: List[Dict]) -> List[Dict]:
|
|||||||
|
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _normalize_anthropic_url(url: str) -> str:
|
def _normalize_anthropic_url(url: str) -> str:
|
||||||
"""Ensure Anthropic URL points to /v1/messages."""
|
"""Ensure Anthropic URL points to /v1/messages."""
|
||||||
url = url.rstrip("/")
|
url = url.rstrip("/")
|
||||||
@@ -1589,6 +1792,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
|||||||
if max_tokens and max_tokens > 0:
|
if max_tokens and max_tokens > 0:
|
||||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||||
payload[tok_key] = max_tokens
|
payload[tok_key] = max_tokens
|
||||||
|
_apply_local_generation_stability(payload, target_url, model)
|
||||||
if provider == "mistral" and _supports_thinking(model):
|
if provider == "mistral" and _supports_thinking(model):
|
||||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||||
try:
|
try:
|
||||||
@@ -1698,6 +1902,7 @@ async def llm_call_async(
|
|||||||
max_retries: int = LLMConfig.MAX_RETRIES,
|
max_retries: int = LLMConfig.MAX_RETRIES,
|
||||||
prompt_type: Optional[str] = None,
|
prompt_type: Optional[str] = None,
|
||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
|
workload: str = "foreground",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging."""
|
"""Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging."""
|
||||||
provider = _detect_provider(url)
|
provider = _detect_provider(url)
|
||||||
@@ -1735,6 +1940,7 @@ async def llm_call_async(
|
|||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
workload=workload,
|
||||||
):
|
):
|
||||||
event_is_error = False
|
event_is_error = False
|
||||||
for line in str(chunk).splitlines():
|
for line in str(chunk).splitlines():
|
||||||
@@ -1800,6 +2006,7 @@ async def llm_call_async(
|
|||||||
if provider == "mistral" and _supports_thinking(model):
|
if provider == "mistral" and _supports_thinking(model):
|
||||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||||
_apply_local_cache_affinity(payload, url, session_id)
|
_apply_local_cache_affinity(payload, url, session_id)
|
||||||
|
_apply_local_generation_stability(payload, target_url, model)
|
||||||
|
|
||||||
if _is_host_dead(target_url):
|
if _is_host_dead(target_url):
|
||||||
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
||||||
@@ -1810,6 +2017,7 @@ async def llm_call_async(
|
|||||||
attempt += 1
|
attempt += 1
|
||||||
start = time.time()
|
start = time.time()
|
||||||
try:
|
try:
|
||||||
|
async with _local_model_slot(target_url, model, workload):
|
||||||
note_model_activity(target_url, model)
|
note_model_activity(target_url, model)
|
||||||
client = _get_http_client()
|
client = _get_http_client()
|
||||||
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
||||||
@@ -1854,10 +2062,45 @@ async def llm_call_async(
|
|||||||
raise HTTPException(502, f"POST {target_url} failed after {max_retries} attempts: {e}")
|
raise HTTPException(502, f"POST {target_url} failed after {max_retries} attempts: {e}")
|
||||||
await asyncio.sleep(LLMConfig.RETRY_DELAY)
|
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,
|
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,
|
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||||
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
||||||
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None):
|
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
|
||||||
|
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.
|
"""Stream LLM responses with improved error handling.
|
||||||
|
|
||||||
Yields SSE chunks:
|
Yields SSE chunks:
|
||||||
@@ -1917,6 +2160,8 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
payload[tok_key] = max_tokens
|
payload[tok_key] = max_tokens
|
||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = tools
|
payload["tools"] = tools
|
||||||
|
elif tool_choice_none:
|
||||||
|
payload["tool_choice"] = "none"
|
||||||
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
||||||
# activates thinking mode and returns structured reasoning_content.
|
# activates thinking mode and returns structured reasoning_content.
|
||||||
# Effort level is configurable via ODYSSEUS_MISTRAL_REASONING_EFFORT
|
# Effort level is configurable via ODYSSEUS_MISTRAL_REASONING_EFFORT
|
||||||
@@ -1929,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):
|
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
|
||||||
payload["think"] = False
|
payload["think"] = False
|
||||||
_apply_local_cache_affinity(payload, url, session_id)
|
_apply_local_cache_affinity(payload, url, session_id)
|
||||||
|
_apply_local_generation_stability(payload, target_url, model)
|
||||||
h = _provider_headers(provider, headers)
|
h = _provider_headers(provider, headers)
|
||||||
if provider == "copilot":
|
if provider == "copilot":
|
||||||
from src.copilot import apply_request_headers
|
from src.copilot import apply_request_headers
|
||||||
@@ -1945,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'
|
yield f'event: error\ndata: {json.dumps({"error": f"Upstream {_host_key(target_url)} unreachable (cooldown active)", "status": 503})}\n\n'
|
||||||
return
|
return
|
||||||
note_model_activity(target_url, model)
|
note_model_activity(target_url, model)
|
||||||
|
degenerate_guard = _DegenerateStreamGuard(model)
|
||||||
|
|
||||||
# ── ChatGPT Subscription / Codex Responses streaming ──
|
# ── ChatGPT Subscription / Codex Responses streaming ──
|
||||||
if provider == "chatgpt-subscription":
|
if provider == "chatgpt-subscription":
|
||||||
@@ -1979,6 +2226,10 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
if evt == "response.output_text.delta":
|
if evt == "response.output_text.delta":
|
||||||
delta = data.get("delta") or ""
|
delta = data.get("delta") or ""
|
||||||
if delta:
|
if delta:
|
||||||
|
_degenerate = degenerate_guard.check(delta)
|
||||||
|
if _degenerate:
|
||||||
|
yield _degenerate
|
||||||
|
return
|
||||||
yield f'data: {json.dumps({"delta": delta})}\n\n'
|
yield f'data: {json.dumps({"delta": delta})}\n\n'
|
||||||
elif evt == "response.completed":
|
elif evt == "response.completed":
|
||||||
usage = (data.get("response") or {}).get("usage") or data.get("usage") or {}
|
usage = (data.get("response") or {}).get("usage") or data.get("usage") or {}
|
||||||
@@ -2311,8 +2562,19 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
reasoning = (reasoning + thinking_part) if reasoning else thinking_part
|
reasoning = (reasoning + thinking_part) if reasoning else thinking_part
|
||||||
content = text_part
|
content = text_part
|
||||||
if reasoning:
|
if reasoning:
|
||||||
|
_degenerate = degenerate_guard.check(reasoning)
|
||||||
|
if _degenerate:
|
||||||
|
yield _degenerate
|
||||||
|
return
|
||||||
yield _stream_delta_event(reasoning, thinking=True)
|
yield _stream_delta_event(reasoning, thinking=True)
|
||||||
if content:
|
if content:
|
||||||
|
content = _strip_visible_chat_template_artifacts(content)
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
_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(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE)
|
||||||
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
|
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
|
||||||
stripped = content.lstrip()
|
stripped = content.lstrip()
|
||||||
|
|||||||
+86
-21
@@ -316,6 +316,83 @@ def _lookup_known(model: str) -> Optional[int]:
|
|||||||
return best_ctx
|
return best_ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _model_ctx_from_entry(m: dict) -> Optional[int]:
|
||||||
|
"""Extract a positive context window from one /models catalog entry.
|
||||||
|
|
||||||
|
Checks the common top-level fields first, then a nested meta/model_extra
|
||||||
|
object. Returns None when no positive window is reported.
|
||||||
|
"""
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
return None
|
||||||
|
for field in (
|
||||||
|
"context_length",
|
||||||
|
"context_window",
|
||||||
|
"max_model_len",
|
||||||
|
"max_context_length",
|
||||||
|
"max_seq_len",
|
||||||
|
):
|
||||||
|
val = m.get(field)
|
||||||
|
if val and isinstance(val, (int, float)) and val > 0:
|
||||||
|
return int(val)
|
||||||
|
meta = m.get("meta") or m.get("model_extra") or {}
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
# n_ctx is the actual serving context (set via -c flag in llama.cpp)
|
||||||
|
for field in ("n_ctx", "context_length", "context_window", "max_model_len"):
|
||||||
|
val = meta.get(field)
|
||||||
|
if val and isinstance(val, (int, float)) and val > 0:
|
||||||
|
return int(val)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Per-endpoint cache of the {model_id: context_length} map parsed from a
|
||||||
|
# proxy/api catalog. api/proxy endpoints skip the /models download on every
|
||||||
|
# lookup because a large catalog is expensive; caching the whole map lets us
|
||||||
|
# pay that download at most once per endpoint instead of once per model.
|
||||||
|
_catalog_ctx_cache: Dict[str, Dict[str, int]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _proxy_catalog_context(endpoint_url: str, model: str) -> Optional[int]:
|
||||||
|
"""Context window for a model read from the endpoint's /models catalog.
|
||||||
|
|
||||||
|
Fetches the catalog once per endpoint and caches the full id->context map,
|
||||||
|
so an api/proxy endpoint serving a model that isn't in KNOWN_CONTEXT_WINDOWS
|
||||||
|
(e.g. a new OpenRouter model) still reports its real window instead of the
|
||||||
|
bare default. Returns None when the catalog can't be read or doesn't list a
|
||||||
|
positive window for the model.
|
||||||
|
"""
|
||||||
|
cat = _catalog_ctx_cache.get(endpoint_url)
|
||||||
|
if cat is None:
|
||||||
|
from src.endpoint_resolver import build_models_url
|
||||||
|
try:
|
||||||
|
r = httpx.get(build_models_url(endpoint_url), timeout=REQUEST_TIMEOUT)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to fetch proxy catalog for context length: {e}")
|
||||||
|
return None
|
||||||
|
if not r.is_success:
|
||||||
|
return None
|
||||||
|
cat = {}
|
||||||
|
try:
|
||||||
|
for m in (r.json().get("data") or []):
|
||||||
|
mid = m.get("id") if isinstance(m, dict) else None
|
||||||
|
ctx = _model_ctx_from_entry(m) if mid else None
|
||||||
|
if mid and ctx:
|
||||||
|
cat[mid] = ctx
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to parse proxy catalog for context length: {e}")
|
||||||
|
return None
|
||||||
|
_catalog_ctx_cache[endpoint_url] = cat
|
||||||
|
|
||||||
|
if model in cat:
|
||||||
|
return cat[model]
|
||||||
|
# Catalog ids may carry a provider prefix (e.g. "openai/gpt-4o") while the
|
||||||
|
# session stores the bare id; match on the trailing segment as a fallback.
|
||||||
|
base = model.split("/")[-1]
|
||||||
|
for mid, ctx in cat.items():
|
||||||
|
if mid.split("/")[-1] == base:
|
||||||
|
return ctx
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _query_context_length(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
def _query_context_length(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
||||||
"""Query the model API for context length. Returns (context_length, known) where
|
"""Query the model API for context length. Returns (context_length, known) where
|
||||||
``known`` is False only for the bare DEFAULT_CONTEXT fallback."""
|
``known`` is False only for the bare DEFAULT_CONTEXT fallback."""
|
||||||
@@ -330,6 +407,14 @@ def _query_context_length(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
|||||||
if known:
|
if known:
|
||||||
logger.info(f"Using known context window for {model}: {known}")
|
logger.info(f"Using known context window for {model}: {known}")
|
||||||
return known, True
|
return known, True
|
||||||
|
# Not in the known table: read the real window from the catalog (cached
|
||||||
|
# once per endpoint) instead of capping every unknown model at the
|
||||||
|
# default — that under-reported large windows on aggregators like
|
||||||
|
# OpenRouter (issue #4886).
|
||||||
|
api_ctx = _proxy_catalog_context(endpoint_url, model)
|
||||||
|
if api_ctx:
|
||||||
|
logger.info(f"Proxy catalog reports context window for {model}: {api_ctx}")
|
||||||
|
return api_ctx, True
|
||||||
return DEFAULT_CONTEXT, False
|
return DEFAULT_CONTEXT, False
|
||||||
|
|
||||||
# Try llama.cpp /slots endpoint first — reports actual serving context
|
# Try llama.cpp /slots endpoint first — reports actual serving context
|
||||||
@@ -370,27 +455,7 @@ def _query_context_length(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
|||||||
for m in models_list:
|
for m in models_list:
|
||||||
mid = m.get("id", "")
|
mid = m.get("id", "")
|
||||||
if mid == model or mid.split("/")[-1] == model.split("/")[-1]:
|
if mid == model or mid.split("/")[-1] == model.split("/")[-1]:
|
||||||
for field in (
|
api_ctx = _model_ctx_from_entry(m)
|
||||||
"context_length",
|
|
||||||
"context_window",
|
|
||||||
"max_model_len",
|
|
||||||
"max_context_length",
|
|
||||||
"max_seq_len",
|
|
||||||
):
|
|
||||||
val = m.get(field)
|
|
||||||
if val and isinstance(val, (int, float)) and val > 0:
|
|
||||||
api_ctx = int(val)
|
|
||||||
break
|
|
||||||
|
|
||||||
if not api_ctx:
|
|
||||||
meta = m.get("meta") or m.get("model_extra") or {}
|
|
||||||
if isinstance(meta, dict):
|
|
||||||
# n_ctx is the actual serving context (set via -c flag in llama.cpp)
|
|
||||||
for field in ("n_ctx", "context_length", "context_window", "max_model_len"):
|
|
||||||
val = meta.get(field)
|
|
||||||
if val and isinstance(val, (int, float)) and val > 0:
|
|
||||||
api_ctx = int(val)
|
|
||||||
break
|
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to query context length for {model}: {e}")
|
logger.debug(f"Failed to query context length for {model}: {e}")
|
||||||
|
|||||||
+2
-2
@@ -32,9 +32,9 @@ class RAGManager:
|
|||||||
logger.info("RAGManager initialized as wrapper for VectorRAG")
|
logger.info("RAGManager initialized as wrapper for VectorRAG")
|
||||||
|
|
||||||
# Delegate all methods to VectorRAG
|
# Delegate all methods to VectorRAG
|
||||||
def search(self, query: str, k: int = 5) -> List[Dict[str, Any]]:
|
def search(self, query: str, k: int = 5, owner: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""Search for documents - delegates to VectorRAG."""
|
"""Search for documents - delegates to VectorRAG."""
|
||||||
return self.vector_rag.search(query, k)
|
return self.vector_rag.search(query, k, owner=owner)
|
||||||
|
|
||||||
def index_personal_documents(
|
def index_personal_documents(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ def _search_like(
|
|||||||
)
|
)
|
||||||
if not include_archived:
|
if not include_archived:
|
||||||
q = q.filter(DBSession.archived == False)
|
q = q.filter(DBSession.archived == False)
|
||||||
|
q = q.filter(~DBSession.name.like("SFT trace batch%"))
|
||||||
if restrict_owner:
|
if restrict_owner:
|
||||||
q = _owner_filter(q, owner, include_legacy_owner)
|
q = _owner_filter(q, owner, include_legacy_owner)
|
||||||
rows = q.order_by(DBChatMessage.timestamp.desc()).limit(limit).all()
|
rows = q.order_by(DBChatMessage.timestamp.desc()).limit(limit).all()
|
||||||
@@ -270,6 +271,7 @@ def _search_fts(
|
|||||||
WHERE chat_messages_fts MATCH :fts_query
|
WHERE chat_messages_fts MATCH :fts_query
|
||||||
{archived_clause}
|
{archived_clause}
|
||||||
{owner_clause}
|
{owner_clause}
|
||||||
|
AND s.name NOT LIKE 'SFT trace batch%'
|
||||||
AND m.role IN ('user', 'assistant')
|
AND m.role IN ('user', 'assistant')
|
||||||
ORDER BY bm25(chat_messages_fts), m.timestamp DESC
|
ORDER BY bm25(chat_messages_fts), m.timestamp DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ DEFAULT_SETTINGS = {
|
|||||||
"task_model": "",
|
"task_model": "",
|
||||||
"default_endpoint_id": "",
|
"default_endpoint_id": "",
|
||||||
"default_model": "",
|
"default_model": "",
|
||||||
|
# Optional prose style used only for normal document writing/editing.
|
||||||
|
# Email replies use email_writing_style instead because greetings,
|
||||||
|
# signatures, and mailbox identity rules are medium-specific.
|
||||||
|
"document_writing_style": "",
|
||||||
# Ordered fallback chain for the default chat model. Each entry is
|
# Ordered fallback chain for the default chat model. Each entry is
|
||||||
# {"endpoint_id": "...", "model": "..."}. If the primary model fails
|
# {"endpoint_id": "...", "model": "..."}. If the primary model fails
|
||||||
# before producing output (endpoint offline / errors), the chat
|
# before producing output (endpoint offline / errors), the chat
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,6 +6,7 @@ from src.endpoint_resolver import (
|
|||||||
resolve_utility_fallback_candidates,
|
resolve_utility_fallback_candidates,
|
||||||
)
|
)
|
||||||
from src.llm_core import llm_call_async_with_fallback
|
from src.llm_core import llm_call_async_with_fallback
|
||||||
|
from src.interactive_gate import wait_for_interactive_quiet
|
||||||
|
|
||||||
|
|
||||||
def resolve_task_endpoint(fallback_url=None, fallback_model=None, fallback_headers=None, owner=None):
|
def resolve_task_endpoint(fallback_url=None, fallback_model=None, fallback_headers=None, owner=None):
|
||||||
@@ -72,4 +73,6 @@ async def task_llm_call_async(
|
|||||||
)
|
)
|
||||||
if not candidates:
|
if not candidates:
|
||||||
raise RuntimeError("No LLM endpoint available for background task")
|
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)
|
return await llm_call_async_with_fallback(candidates, messages=messages, **kwargs)
|
||||||
|
|||||||
+165
-8
@@ -10,6 +10,10 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||||
|
|
||||||
from core.auth import RESERVED_USERNAMES
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -239,6 +243,7 @@ HOUSEKEEPING_DEFAULTS = {
|
|||||||
"tidy_research": {"name": "Research Tidy", "trigger_type": "event", "trigger_event": "research_completed", "trigger_count": 5, "schedule": None, "scheduled_time": None, "cron_expression": None, "legacy_names": ["Tidy Research"]},
|
"tidy_research": {"name": "Research Tidy", "trigger_type": "event", "trigger_event": "research_completed", "trigger_count": 5, "schedule": None, "scheduled_time": None, "cron_expression": None, "legacy_names": ["Tidy Research"]},
|
||||||
"summarize_emails": {"name": "Email (Summary)", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */2 * * *", "ship_paused": True, "legacy_names": ["Tidy Email (Summary)"]},
|
"summarize_emails": {"name": "Email (Summary)", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */2 * * *", "ship_paused": True, "legacy_names": ["Tidy Email (Summary)"]},
|
||||||
"draft_email_replies": {"name": "Email AI Auto Reply", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */2 * * *", "ship_paused": True, "legacy_names": ["Tidy Email (Replies)", "AI Auto Reply"]},
|
"draft_email_replies": {"name": "Email AI Auto Reply", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */2 * * *", "ship_paused": True, "legacy_names": ["Tidy Email (Replies)", "AI Auto Reply"]},
|
||||||
|
"email_auto_translate": {"name": "Email Auto Translate", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */2 * * *", "ship_paused": True, "legacy_names": ["Auto-translate Emails", "Auto Translate Email"]},
|
||||||
"extract_email_events": {"name": "Email Calendar Events", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */1 * * *", "ship_paused": True, "legacy_names": ["Email → Calendar Events"]},
|
"extract_email_events": {"name": "Email Calendar Events", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */1 * * *", "ship_paused": True, "legacy_names": ["Email → Calendar Events"]},
|
||||||
"classify_events": {"name": "Calendar Classify Events", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 6,18 * * *", "ship_paused": True, "legacy_names": ["Classify Calendar Events"]},
|
"classify_events": {"name": "Calendar Classify Events", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 6,18 * * *", "ship_paused": True, "legacy_names": ["Classify Calendar Events"]},
|
||||||
"check_email_urgency": {"name": "Email Tags", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 * * * *", "ship_paused": True, "old_cron_expressions": ["*/15 * * * *"], "legacy_names": ["Email Triage", "Urgent Email"]},
|
"check_email_urgency": {"name": "Email Tags", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 * * * *", "ship_paused": True, "old_cron_expressions": ["*/15 * * * *"], "legacy_names": ["Email Triage", "Urgent Email"]},
|
||||||
@@ -686,6 +691,12 @@ class TaskScheduler:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
|
foreground_active = False
|
||||||
|
try:
|
||||||
|
from src.interactive_gate import has_foreground_activity
|
||||||
|
foreground_active = has_foreground_activity()
|
||||||
|
except Exception:
|
||||||
|
foreground_active = False
|
||||||
async with self._executing_lock:
|
async with self._executing_lock:
|
||||||
# Snapshot under the lock so we don't race with mid-iteration adds.
|
# Snapshot under the lock so we don't race with mid-iteration adds.
|
||||||
executing_snapshot = set(self._executing)
|
executing_snapshot = set(self._executing)
|
||||||
@@ -699,8 +710,13 @@ class TaskScheduler:
|
|||||||
for task in due:
|
for task in due:
|
||||||
if task.id in self._executing:
|
if task.id in self._executing:
|
||||||
continue
|
continue
|
||||||
|
if foreground_active:
|
||||||
|
task.next_run = now + timedelta(minutes=15)
|
||||||
|
continue
|
||||||
self._executing.add(task.id)
|
self._executing.add(task.id)
|
||||||
to_dispatch.append(task.id)
|
to_dispatch.append(task.id)
|
||||||
|
if foreground_active and due:
|
||||||
|
db.commit()
|
||||||
for task_id in to_dispatch:
|
for task_id in to_dispatch:
|
||||||
asyncio.create_task(self._execute_task(task_id))
|
asyncio.create_task(self._execute_task(task_id))
|
||||||
finally:
|
finally:
|
||||||
@@ -734,15 +750,26 @@ class TaskScheduler:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if bypass_model_slot or not self._task_needs_model_slot(task_id):
|
if bypass_model_slot or not self._task_needs_model_slot(task_id):
|
||||||
await self._execute_task_locked(task_id, run_id, release_executing=release_executing)
|
await self._execute_task_locked(
|
||||||
|
task_id,
|
||||||
|
run_id,
|
||||||
|
release_executing=release_executing,
|
||||||
|
gate_foreground=not bypass_model_slot,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
async with self._run_semaphore:
|
async with self._run_semaphore:
|
||||||
await self._execute_task_locked(task_id, run_id, release_executing=release_executing)
|
await self._execute_task_locked(
|
||||||
|
task_id,
|
||||||
|
run_id,
|
||||||
|
release_executing=release_executing,
|
||||||
|
gate_foreground=True,
|
||||||
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# If cancellation happens while queued behind the semaphore,
|
# If cancellation happens while queued behind the semaphore,
|
||||||
# _execute_task_locked never runs and cannot update the Activity row.
|
# _execute_task_locked never runs and cannot update the Activity row.
|
||||||
self._mark_run_aborted(task_id, run_id)
|
self._mark_run_aborted(task_id, run_id)
|
||||||
|
self._defer_immediately_due_task(task_id, delay=timedelta(minutes=15))
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
handle = self._task_handles.get(task_id)
|
handle = self._task_handles.get(task_id)
|
||||||
@@ -752,7 +779,36 @@ class TaskScheduler:
|
|||||||
async with self._executing_lock:
|
async with self._executing_lock:
|
||||||
self._executing.discard(task_id)
|
self._executing.discard(task_id)
|
||||||
|
|
||||||
async def _execute_task_locked(self, task_id: str, run_id: str, *, release_executing: bool = True):
|
def _defer_immediately_due_task(self, task_id: str, *, delay: timedelta):
|
||||||
|
"""A queued task can be cancelled before _execute_task_locked gets a DB
|
||||||
|
handle. If its next_run stays in the past, the scheduler dispatches it
|
||||||
|
again on the next tick and spams aborted Activity rows."""
|
||||||
|
try:
|
||||||
|
from core.database import SessionLocal, ScheduledTask
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
|
if (
|
||||||
|
task
|
||||||
|
and task.status == "active"
|
||||||
|
and task.next_run is not None
|
||||||
|
and task.next_run <= _utcnow()
|
||||||
|
):
|
||||||
|
task.next_run = _utcnow() + delay
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to defer cancelled queued task %s", task_id, exc_info=True)
|
||||||
|
|
||||||
|
async def _execute_task_locked(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
run_id: str,
|
||||||
|
*,
|
||||||
|
release_executing: bool = True,
|
||||||
|
gate_foreground: bool = True,
|
||||||
|
):
|
||||||
from core.database import SessionLocal, ScheduledTask, TaskRun
|
from core.database import SessionLocal, ScheduledTask, TaskRun
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -769,6 +825,36 @@ class TaskScheduler:
|
|||||||
db.commit()
|
db.commit()
|
||||||
return
|
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":
|
||||||
|
waiting.result = "Queued — waiting for Odysseus to be idle…"
|
||||||
|
db.commit()
|
||||||
|
from src.interactive_gate import wait_for_interactive_quiet
|
||||||
|
await wait_for_interactive_quiet(f"scheduled task {task.name}")
|
||||||
|
|
||||||
# Flip the run from queued → running. Reset started_at to the
|
# Flip the run from queued → running. Reset started_at to the
|
||||||
# actual execution start so queue wait time is visible from
|
# actual execution start so queue wait time is visible from
|
||||||
# created_at vs started_at if we ever surface that.
|
# created_at vs started_at if we ever surface that.
|
||||||
@@ -799,6 +885,27 @@ class TaskScheduler:
|
|||||||
# previous llm/research run's model. The executors set it once the
|
# previous llm/research run's model. The executors set it once the
|
||||||
# model is resolved.
|
# model is resolved.
|
||||||
self._last_run_model = None
|
self._last_run_model = None
|
||||||
|
foreground_cancel = {"hit": False}
|
||||||
|
foreground_monitor = None
|
||||||
|
if gate_foreground:
|
||||||
|
current_task = asyncio.current_task()
|
||||||
|
|
||||||
|
async def _cancel_if_foreground_active():
|
||||||
|
# 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(0.1)
|
||||||
|
from src.interactive_gate import has_foreground_activity
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
if has_foreground_activity():
|
||||||
|
foreground_cancel["hit"] = True
|
||||||
|
logger.info("Task '%s' interrupted because Odysseus became active", task.name)
|
||||||
|
if current_task:
|
||||||
|
current_task.cancel()
|
||||||
|
return
|
||||||
|
|
||||||
|
foreground_monitor = asyncio.create_task(_cancel_if_foreground_active())
|
||||||
try:
|
try:
|
||||||
if task_type == "action":
|
if task_type == "action":
|
||||||
result, success = await self._execute_action(task, run_id=run_id)
|
result, success = await self._execute_action(task, run_id=run_id)
|
||||||
@@ -838,15 +945,22 @@ class TaskScheduler:
|
|||||||
db.commit()
|
db.commit()
|
||||||
return
|
return
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("Task '%s' stopped by user", task.name)
|
msg = (
|
||||||
|
"Paused because Odysseus became active"
|
||||||
|
if foreground_cancel.get("hit")
|
||||||
|
else "Stopped by user"
|
||||||
|
)
|
||||||
|
logger.info("Task '%s' %s", task.name, msg)
|
||||||
run_obj = db.query(TaskRun).filter(TaskRun.id == run_id).first()
|
run_obj = db.query(TaskRun).filter(TaskRun.id == run_id).first()
|
||||||
if run_obj:
|
if run_obj:
|
||||||
run_obj.status = "aborted"
|
run_obj.status = "aborted"
|
||||||
run_obj.error = "Stopped by user"
|
run_obj.error = msg
|
||||||
run_obj.result = run_obj.result or "Stopped by user"
|
run_obj.result = run_obj.result or msg
|
||||||
run_obj.finished_at = _utcnow()
|
run_obj.finished_at = _utcnow()
|
||||||
task.last_run = _utcnow()
|
task.last_run = _utcnow()
|
||||||
if (task.trigger_type or "schedule") == "schedule":
|
if foreground_cancel.get("hit"):
|
||||||
|
task.next_run = _utcnow() + timedelta(minutes=15)
|
||||||
|
elif (task.trigger_type or "schedule") == "schedule":
|
||||||
task.next_run = compute_next_run(
|
task.next_run = compute_next_run(
|
||||||
task.schedule, task.scheduled_time,
|
task.schedule, task.scheduled_time,
|
||||||
task.scheduled_day, task.scheduled_date,
|
task.scheduled_day, task.scheduled_date,
|
||||||
@@ -881,6 +995,13 @@ class TaskScheduler:
|
|||||||
task.next_run = None
|
task.next_run = None
|
||||||
db.commit()
|
db.commit()
|
||||||
return
|
return
|
||||||
|
finally:
|
||||||
|
if foreground_monitor and not foreground_monitor.done():
|
||||||
|
foreground_monitor.cancel()
|
||||||
|
try:
|
||||||
|
await foreground_monitor
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
run.finished_at = _utcnow()
|
run.finished_at = _utcnow()
|
||||||
|
|
||||||
@@ -1050,6 +1171,7 @@ class TaskScheduler:
|
|||||||
"learn_sender_signatures",
|
"learn_sender_signatures",
|
||||||
"summarize_emails",
|
"summarize_emails",
|
||||||
"draft_email_replies",
|
"draft_email_replies",
|
||||||
|
"email_auto_translate",
|
||||||
"extract_email_events",
|
"extract_email_events",
|
||||||
"classify_events",
|
"classify_events",
|
||||||
"tidy_sessions",
|
"tidy_sessions",
|
||||||
@@ -1063,6 +1185,7 @@ class TaskScheduler:
|
|||||||
_MODEL_BACKED_ACTIONS = frozenset({
|
_MODEL_BACKED_ACTIONS = frozenset({
|
||||||
"summarize_emails",
|
"summarize_emails",
|
||||||
"draft_email_replies",
|
"draft_email_replies",
|
||||||
|
"email_auto_translate",
|
||||||
"extract_email_events",
|
"extract_email_events",
|
||||||
"classify_events",
|
"classify_events",
|
||||||
"learn_sender_signatures",
|
"learn_sender_signatures",
|
||||||
@@ -1119,6 +1242,8 @@ class TaskScheduler:
|
|||||||
self._set_run_progress(run_id, message)
|
self._set_run_progress(run_id, message)
|
||||||
|
|
||||||
kwargs = {"owner": task.owner, "task_name": task.name, "progress_cb": _progress}
|
kwargs = {"owner": task.owner, "task_name": task.name, "progress_cb": _progress}
|
||||||
|
if task.prompt:
|
||||||
|
kwargs["prompt"] = task.prompt
|
||||||
if task.action in ("run_script", "run_local", "ssh_command") and task.prompt:
|
if task.action in ("run_script", "run_local", "ssh_command") and task.prompt:
|
||||||
kwargs["script" if task.action in ("run_script", "run_local") else "command"] = task.prompt
|
kwargs["script" if task.action in ("run_script", "run_local") else "command"] = task.prompt
|
||||||
# cookbook_serve carries its JSON config in task.prompt — feed it
|
# cookbook_serve carries its JSON config in task.prompt — feed it
|
||||||
@@ -1682,8 +1807,15 @@ class TaskScheduler:
|
|||||||
|
|
||||||
target = (output or "").strip()
|
target = (output or "").strip()
|
||||||
explicit = ""
|
explicit = ""
|
||||||
|
account_id = ""
|
||||||
if target.startswith("email:"):
|
if target.startswith("email:"):
|
||||||
explicit = target.split(":", 1)[1].strip()
|
explicit = target.split(":", 1)[1].strip()
|
||||||
|
if "|account=" in explicit:
|
||||||
|
explicit, account_id = explicit.split("|account=", 1)
|
||||||
|
explicit = explicit.strip()
|
||||||
|
account_id = account_id.strip()
|
||||||
|
if explicit == "self":
|
||||||
|
explicit = ""
|
||||||
elif "@" in target:
|
elif "@" in target:
|
||||||
explicit = target
|
explicit = target
|
||||||
|
|
||||||
@@ -1691,7 +1823,7 @@ class TaskScheduler:
|
|||||||
from routes.email_routes import _resolve_send_config
|
from routes.email_routes import _resolve_send_config
|
||||||
from routes.email_helpers import _send_smtp_message
|
from routes.email_helpers import _send_smtp_message
|
||||||
|
|
||||||
cfg = _resolve_send_config(owner=task.owner or "")
|
cfg = _resolve_send_config(account_id=account_id or None, owner=task.owner or "")
|
||||||
to_addr = explicit or cfg.get("from_address") or cfg.get("smtp_user") or ""
|
to_addr = explicit or cfg.get("from_address") or cfg.get("smtp_user") or ""
|
||||||
if not to_addr:
|
if not to_addr:
|
||||||
raise RuntimeError("No email recipient resolved for task output")
|
raise RuntimeError("No email recipient resolved for task output")
|
||||||
@@ -1759,6 +1891,8 @@ class TaskScheduler:
|
|||||||
# behind the primary endpoint so a downed primary won't silently yield
|
# behind the primary endpoint so a downed primary won't silently yield
|
||||||
# `(no output)`.
|
# `(no output)`.
|
||||||
try:
|
try:
|
||||||
|
from src.interactive_gate import wait_for_interactive_quiet
|
||||||
|
await wait_for_interactive_quiet(f"agent task {task.name}")
|
||||||
from src.task_endpoint import resolve_task_candidates
|
from src.task_endpoint import resolve_task_candidates
|
||||||
_task_fallbacks = resolve_task_candidates(
|
_task_fallbacks = resolve_task_candidates(
|
||||||
fallback_url=endpoint_url,
|
fallback_url=endpoint_url,
|
||||||
@@ -1779,6 +1913,7 @@ class TaskScheduler:
|
|||||||
disabled_tools=disabled_tools,
|
disabled_tools=disabled_tools,
|
||||||
relevant_tools=relevant_tools,
|
relevant_tools=relevant_tools,
|
||||||
fallbacks=_task_fallbacks,
|
fallbacks=_task_fallbacks,
|
||||||
|
workload="background",
|
||||||
):
|
):
|
||||||
if event_str.startswith("data: ") and not event_str.startswith("data: [DONE]"):
|
if event_str.startswith("data: ") and not event_str.startswith("data: [DONE]"):
|
||||||
try:
|
try:
|
||||||
@@ -2105,6 +2240,28 @@ class TaskScheduler:
|
|||||||
stopped = self._mark_run_aborted(task_id) or stopped
|
stopped = self._mark_run_aborted(task_id) or stopped
|
||||||
return 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):
|
async def ensure_defaults(self, owner: str):
|
||||||
"""Create default housekeeping tasks for this owner (idempotent per action)."""
|
"""Create default housekeeping tasks for this owner (idempotent per action)."""
|
||||||
from core.database import SessionLocal, ScheduledTask
|
from core.database import SessionLocal, ScheduledTask
|
||||||
|
|||||||
+111
-11
@@ -21,7 +21,12 @@ from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
from src.tool_security import is_public_blocked_tool, owner_is_admin_or_single_user
|
from src.tool_security import (
|
||||||
|
BUILTIN_EMAIL_TOOLS,
|
||||||
|
email_tool_policy_names,
|
||||||
|
is_public_blocked_tool,
|
||||||
|
owner_is_admin_or_single_user,
|
||||||
|
)
|
||||||
from src.tool_policy import ToolPolicy
|
from src.tool_policy import ToolPolicy
|
||||||
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
|
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
|
||||||
from src.tool_utils import _truncate, get_mcp_manager
|
from src.tool_utils import _truncate, get_mcp_manager
|
||||||
@@ -66,25 +71,35 @@ _SENSITIVE_FILE_PATTERNS: tuple[str, ...] = (
|
|||||||
"known_hosts",
|
"known_hosts",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Case-folded views used for matching. On a case-insensitive filesystem
|
||||||
|
# (Windows, default macOS) ".SSH/AUTHORIZED_KEYS" and ".env" resolve to the
|
||||||
|
# same protected files as their lowercase forms, so the deny-list has to fold
|
||||||
|
# case before comparing — the sibling resolver already normcases paths for the
|
||||||
|
# same reason. casefold (not os.path.normcase) because normcase is a no-op on
|
||||||
|
# POSIX, which is exactly where the macOS read-exfil path lives.
|
||||||
|
_SENSITIVE_BASENAMES_CF: frozenset[str] = frozenset(b.casefold() for b in _SENSITIVE_BASENAMES)
|
||||||
|
_SENSITIVE_FILE_PATTERNS_CF: frozenset[str] = frozenset(p.casefold() for p in _SENSITIVE_FILE_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
def _is_sensitive_path(resolved: str) -> bool:
|
def _is_sensitive_path(resolved: str) -> bool:
|
||||||
"""Return True if *resolved* falls under a sensitive directory or
|
"""Return True if *resolved* falls under a sensitive directory or
|
||||||
matches a sensitive filename — regardless of what root it sits under.
|
matches a sensitive filename — regardless of what root it sits under.
|
||||||
|
|
||||||
|
Matching is case-insensitive: on Windows / default macOS a case-variant
|
||||||
|
name (``.SSH``, ``AUTHORIZED_KEYS``, ``Id_Rsa``) points at the same file as
|
||||||
|
the lowercase form, so a case-sensitive check would let it slip past the
|
||||||
|
deny-list in every file tool that relies on it.
|
||||||
"""
|
"""
|
||||||
parts = resolved.split(os.sep)
|
parts = [p.casefold() for p in resolved.split(os.sep)]
|
||||||
filenames: set[str] = {parts[-1]} if parts else set()
|
filename = parts[-1] if parts else ""
|
||||||
|
|
||||||
# Check if any path component is a sensitive directory.
|
# Check if any path component is a sensitive directory.
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if part in _SENSITIVE_BASENAMES:
|
if part in _SENSITIVE_BASENAMES_CF:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check filename against known sensitive files.
|
# Check filename against known sensitive files.
|
||||||
for pat in _SENSITIVE_FILE_PATTERNS:
|
return filename in _SENSITIVE_FILE_PATTERNS_CF
|
||||||
if pat in filenames:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_path_roots() -> list[str]:
|
def _tool_path_roots() -> list[str]:
|
||||||
@@ -390,8 +405,42 @@ _MCP_ARG_PARSERS: Dict[str, Callable[[str], Dict[str, str]]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Primary argument key(s) for the legacy line-parsed tools. When a fenced
|
||||||
|
# block's content is a JSON object carrying one of these keys, it's structured
|
||||||
|
# inline args (the relaxed parser's ```web_search {"query": "..."}``` shape) —
|
||||||
|
# use the object directly instead of letting the line-based parsers wrap the
|
||||||
|
# whole JSON string as the query/url/path/prompt. Keyed off membership only
|
||||||
|
# (the primary key never changes), so this can't drift; an unrecognized object
|
||||||
|
# safely falls through to the line-based parser, i.e. the previous behavior.
|
||||||
|
#
|
||||||
|
# IMPORTANT — this only covers the MCP path. _build_mcp_args is reached via
|
||||||
|
# _call_mcp_tool only for _MCP_TOOL_MAP tools (so an entry outside that map is
|
||||||
|
# dead, as manage_memory was). And of these, only generate_image has a live MCP
|
||||||
|
# server today; web_search/web_fetch/read_file/write_file have none, so they run
|
||||||
|
# via _direct_fallback -> TOOL_HANDLERS, whose handlers decode JSON themselves
|
||||||
|
# (see ReadFileTool/WriteFileTool/WebSearchTool/WebFetchTool). The entries here
|
||||||
|
# are kept as defense-in-depth for if/when those servers are added. The live
|
||||||
|
# fix for each server-less tool lives in its handler. test_write_file_inline_
|
||||||
|
# json_args and test_mcp_json_primary_keys_are_all_live pin both halves.
|
||||||
|
_MCP_JSON_PRIMARY_KEYS: Dict[str, tuple] = {
|
||||||
|
"web_search": ("query", "queries"),
|
||||||
|
"web_fetch": ("url",),
|
||||||
|
"read_file": ("path",),
|
||||||
|
"write_file": ("path",),
|
||||||
|
"generate_image": ("prompt",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_mcp_args(tool: str, content: str) -> Dict:
|
def _build_mcp_args(tool: str, content: str) -> Dict:
|
||||||
"""Convert fenced-block text content to structured MCP arguments."""
|
"""Convert fenced-block text content to structured MCP arguments."""
|
||||||
|
primaries = _MCP_JSON_PRIMARY_KEYS.get(tool)
|
||||||
|
if primaries and content.strip().startswith("{"):
|
||||||
|
try:
|
||||||
|
decoded = json.loads(content.strip())
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
decoded = None
|
||||||
|
if isinstance(decoded, dict) and any(k in decoded for k in primaries):
|
||||||
|
return decoded
|
||||||
parser = _MCP_ARG_PARSERS.get(tool)
|
parser = _MCP_ARG_PARSERS.get(tool)
|
||||||
return parser(content) if parser else {}
|
return parser(content) if parser else {}
|
||||||
|
|
||||||
@@ -596,6 +645,12 @@ async def _execute_tool_block_impl(
|
|||||||
tool = block.tool_type
|
tool = block.tool_type
|
||||||
content = block.content
|
content = block.content
|
||||||
|
|
||||||
|
# The block/disable gates below must match every policy-equivalent
|
||||||
|
# spelling of the tool name (bare email names alias their mcp__email__
|
||||||
|
# form — see email_tool_policy_names), not just the spelling the model
|
||||||
|
# happened to emit.
|
||||||
|
policy_names = email_tool_policy_names(tool)
|
||||||
|
|
||||||
# Misformatted tool call detection: model put JSON inside ```python``` (or
|
# Misformatted tool call detection: model put JSON inside ```python``` (or
|
||||||
# similar) without naming the tool. Common with MiniMax-style outputs.
|
# similar) without naming the tool. Common with MiniMax-style outputs.
|
||||||
# Return a helpful error so the model retries with the correct format.
|
# Return a helpful error so the model retries with the correct format.
|
||||||
@@ -623,13 +678,13 @@ async def _execute_tool_block_impl(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Reject tools that the user has disabled for this request
|
# Reject tools that the user has disabled for this request
|
||||||
if disabled_tools and tool in disabled_tools:
|
if disabled_tools and not policy_names.isdisjoint(disabled_tools):
|
||||||
desc = f"{tool}: BLOCKED"
|
desc = f"{tool}: BLOCKED"
|
||||||
result = {"error": f"Tool '{tool}' is disabled by user.", "exit_code": 1}
|
result = {"error": f"Tool '{tool}' is disabled by user.", "exit_code": 1}
|
||||||
logger.info(f"Tool blocked by user: {tool}")
|
logger.info(f"Tool blocked by user: {tool}")
|
||||||
return desc, result
|
return desc, result
|
||||||
|
|
||||||
if tool_policy and tool_policy.blocks(tool):
|
if tool_policy and any(tool_policy.blocks(name) for name in policy_names):
|
||||||
desc = f"{tool}: BLOCKED"
|
desc = f"{tool}: BLOCKED"
|
||||||
result = {
|
result = {
|
||||||
"error": f"Execution of tool '{tool}' is forbade by the active guide-only policy.",
|
"error": f"Execution of tool '{tool}' is forbade by the active guide-only policy.",
|
||||||
@@ -823,6 +878,51 @@ async def _execute_tool_block_impl(
|
|||||||
elif tool == "vault_unlock":
|
elif tool == "vault_unlock":
|
||||||
desc = "vault_unlock"
|
desc = "vault_unlock"
|
||||||
result = await do_vault_unlock(content, owner=owner)
|
result = await do_vault_unlock(content, owner=owner)
|
||||||
|
elif tool in BUILTIN_EMAIL_TOOLS:
|
||||||
|
# Bare email tool name from fenced-block models (e.g. Ollama) — route to MCP email server.
|
||||||
|
# Non-admin owners never reach here: BUILTIN_EMAIL_TOOLS ⊆ NON_ADMIN_BLOCKED_TOOLS,
|
||||||
|
# so is_public_blocked_tool() above already rejected them.
|
||||||
|
mcp = get_mcp_manager()
|
||||||
|
qualified = f"mcp__email__{tool}"
|
||||||
|
desc = f"email: {tool}"
|
||||||
|
if mcp:
|
||||||
|
_raw = content.strip()
|
||||||
|
args = {}
|
||||||
|
_args_error = None
|
||||||
|
if _raw:
|
||||||
|
# A non-empty body is always meant to be the call's arguments,
|
||||||
|
# and every email tool takes a JSON object. Anything that
|
||||||
|
# isn't one is a correctable error — NOT a silent empty-args
|
||||||
|
# call, which would read the DEFAULT mailbox/folder instead of
|
||||||
|
# the one the model meant (#3966 class). Only an EMPTY body
|
||||||
|
# keeps the no-arg path (e.g. ```list_email_accounts```).
|
||||||
|
try:
|
||||||
|
parsed = json.loads(_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError) as _je:
|
||||||
|
# Covers both `{account: "work"}` (looks like JSON, bad)
|
||||||
|
# and `account: work` (not JSON at all).
|
||||||
|
_args_error = (
|
||||||
|
f"'{tool}' arguments are not valid JSON ({_je}). "
|
||||||
|
'Send a JSON object, e.g. {"account": "work"} — '
|
||||||
|
"keys and string values need double quotes."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
args = parsed
|
||||||
|
else:
|
||||||
|
_args_error = (
|
||||||
|
f"'{tool}' arguments must be a JSON object, "
|
||||||
|
'e.g. {"uid": "..."} — got a JSON array/value instead.'
|
||||||
|
)
|
||||||
|
if _args_error is not None:
|
||||||
|
result = {"error": _args_error, "exit_code": 1}
|
||||||
|
else:
|
||||||
|
if owner:
|
||||||
|
args = dict(args)
|
||||||
|
args[_EMAIL_MCP_OWNER_ARG] = owner
|
||||||
|
result = await mcp.call_tool(qualified, args)
|
||||||
|
else:
|
||||||
|
result = {"error": "MCP manager not available", "exit_code": 1}
|
||||||
elif tool.startswith("mcp__"):
|
elif tool.startswith("mcp__"):
|
||||||
# MCP tool dispatch
|
# MCP tool dispatch
|
||||||
mcp = get_mcp_manager()
|
mcp = get_mcp_manager()
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ def __getattr__(name):
|
|||||||
from src.agent_tools import admin_tools
|
from src.agent_tools import admin_tools
|
||||||
return getattr(admin_tools, name)
|
return getattr(admin_tools, name)
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|
||||||
|
|
||||||
# Cookbook (model serving) domain extracted to src/tools/cookbook.py
|
# Cookbook (model serving) domain extracted to src/tools/cookbook.py
|
||||||
# (slice 1, #4082/#4071). Re-imported here so this module stays a working
|
# (slice 1, #4082/#4071). Re-imported here so this module stays a working
|
||||||
# facade. cookbook.py pulls `_internal_headers` / `_INTERNAL_BASE` back
|
# facade. cookbook.py pulls `_internal_headers` / `_INTERNAL_BASE` back
|
||||||
|
|||||||
+7
-5
@@ -105,12 +105,12 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
|||||||
"search_chats": "Search past session transcripts across chats.",
|
"search_chats": "Search past session transcripts across chats.",
|
||||||
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Omit `multi`/keep it false unless the question explicitly permits choosing multiple options. Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
|
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Omit `multi`/keep it false unless the question explicitly permits choosing multiple options. Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
|
||||||
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
|
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
|
||||||
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply <body text>` (or structured body) to open an email reply draft document without sending. USE THIS whenever the user says to write/draft a reply or tells you what to say — opening an empty draft or sending immediately is wrong. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
||||||
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
||||||
"list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.",
|
"list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.",
|
||||||
"read_email": "Read the full content of a specific email by UID or Message-ID. View email body, check details. Supports account from list_email_accounts when the UID belongs to a non-default mailbox.",
|
"read_email": "Read the full content of a specific email by UID or Message-ID. View email body, check details. Supports account from list_email_accounts when the UID belongs to a non-default mailbox.",
|
||||||
"send_email": "Send a new email via SMTP. Provide recipient, subject, body, and optional account from list_email_accounts. For replying to a thread use reply_to_email instead.",
|
"send_email": "Send a new email via SMTP. Provide recipient, subject, body, and optional account from list_email_accounts. For replying to a thread use reply_to_email instead.",
|
||||||
"reply_to_email": "SEND a reply email immediately by UID. Do not use for open/start reply draft requests; use ui_control open_email_reply for those. For follow-up 'reply ...' send requests, use the exact UID and account from latest read_email/list_emails output; never invent UID 1. Threads automatically with In-Reply-To/References, prefixes Re:, marks original as Answered.",
|
"reply_to_email": "SEND a reply email immediately by UID. Do not use for write/draft/open/start reply requests; use ui_control open_email_reply with body so the user can review. Only use when the user explicitly says to send now. For send requests, use the exact UID and account from latest read_email/list_emails output; never invent UID 1. Threads automatically with In-Reply-To/References, prefixes Re:, marks original as Answered.",
|
||||||
"archive_email": "Move an email out of the inbox into the Archive folder. Use after handling messages you want to keep but get out of the way.",
|
"archive_email": "Move an email out of the inbox into the Archive folder. Use after handling messages you want to keep but get out of the way.",
|
||||||
"delete_email": "Delete an email — moves to Trash by default, or expunges permanently with permanent=true.",
|
"delete_email": "Delete an email — moves to Trash by default, or expunges permanently with permanent=true.",
|
||||||
"mark_email_read": "Mark an email as read or unread by toggling the \\Seen flag.",
|
"mark_email_read": "Mark an email as read or unread by toggling the \\Seen flag.",
|
||||||
@@ -118,7 +118,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
|||||||
"resolve_contact": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]', 'email [name]', or 'send to [name]' without an email address.",
|
"resolve_contact": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]', 'email [name]', or 'send to [name]' without an email address.",
|
||||||
"manage_contact": "Save / update / delete / list address-book contacts (CardDAV). Use for info about ANOTHER person — name, email, phone, postal address. Args: action=list|add|update|delete, name, email, phones, address, uid (from list). For 'save this for <person>' / address pastes / phone numbers next to a name, this is the right tool — NOT manage_memory. Do NOT use for facts about the USER ('my name is X'); those are manage_memory.",
|
"manage_contact": "Save / update / delete / list address-book contacts (CardDAV). Use for info about ANOTHER person — name, email, phone, postal address. Args: action=list|add|update|delete, name, email, phones, address, uid (from list). For 'save this for <person>' / address pastes / phone numbers next to a name, this is the right tool — NOT manage_memory. Do NOT use for facts about the USER ('my name is X'); those are manage_memory.",
|
||||||
"manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.",
|
"manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.",
|
||||||
"manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Resolve today/tomorrow using the Current date and time context, then use ISO datetimes in the user's local wall time; supports all-day events. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.",
|
"manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Resolve today/tomorrow using the Current date and time context, then use ISO datetimes in the user's local wall time; supports all-day events. Use rrule only for explicit recurrence; for update_event pass rrule='' to remove repeats. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.",
|
||||||
"download_model": "Download a HuggingFace model to a local or remote server. Specify repo_id (e.g. 'Qwen/Qwen3-8B'), optional server host, and optional include filter for specific files.",
|
"download_model": "Download a HuggingFace model to a local or remote server. Specify repo_id (e.g. 'Qwen/Qwen3-8B'), optional server host, and optional include filter for specific files.",
|
||||||
"serve_model": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. cmd MUST start with the binary directly — e.g. `vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --port 8003 --tensor-parallel-size 8 …`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||` — those get rejected by the validator. The venv activation (env_prefix) and CUDA env are added automatically from the target host's saved settings. For image/inpainting/diffusion use python3 scripts/diffusion_server.py --model <repo> --port 8100. After launch, call list_served_models for readiness/errors and retry suggestions. If serve_model fails with 'Invalid characters in cmd', simplify to the bare binary + args.",
|
"serve_model": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. cmd MUST start with the binary directly — e.g. `vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --port 8003 --tensor-parallel-size 8 …`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||` — those get rejected by the validator. The venv activation (env_prefix) and CUDA env are added automatically from the target host's saved settings. For image/inpainting/diffusion use python3 scripts/diffusion_server.py --model <repo> --port 8100. After launch, call list_served_models for readiness/errors and retry suggestions. If serve_model fails with 'Invalid characters in cmd', simplify to the bare binary + args.",
|
||||||
"list_served_models": "List currently running model servers in the Cookbook — shows status (loading, ready, idle, error), model name, port, throughput, and serve failure diagnosis/retry suggestions. Use when the user asks 'what's running', 'show my cookbook', 'which models are up', 'what's serving'.",
|
"list_served_models": "List currently running model servers in the Cookbook — shows status (loading, ready, idle, error), model name, port, throughput, and serve failure diagnosis/retry suggestions. Use when the user asks 'what's running', 'show my cookbook', 'which models are up', 'what's serving'.",
|
||||||
@@ -405,8 +405,10 @@ class ToolIndex:
|
|||||||
{"chat_with_model", "ask_teacher", "list_models"},
|
{"chat_with_model", "ask_teacher", "list_models"},
|
||||||
# Deep research intent (incl. common typo "reserach")
|
# Deep research intent (incl. common typo "reserach")
|
||||||
frozenset({"web search", "search the web", "search online", "look up",
|
frozenset({"web search", "search the web", "search online", "look up",
|
||||||
"google", "latest", "current", "news", "weather",
|
"find info online", "find information online",
|
||||||
"forecast", "stock price", "price of"}):
|
"find info", "find information", "online about",
|
||||||
|
"on the internet", "google", "latest", "current", "news",
|
||||||
|
"weather", "forecast", "stock price", "price of"}):
|
||||||
{"web_search", "web_fetch"},
|
{"web_search", "web_fetch"},
|
||||||
frozenset({"research", "reserach", "reasearch", "look into", "investigate",
|
frozenset({"research", "reserach", "reasearch", "look into", "investigate",
|
||||||
"deep dive", "deep research", "find out about", "study up on",
|
"deep dive", "deep research", "find out about", "study up on",
|
||||||
|
|||||||
+401
-6
@@ -10,9 +10,10 @@ import bisect
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from src.agent_tools import ToolBlock, TOOL_TAGS
|
from src.agent_tools import ToolBlock, TOOL_TAGS
|
||||||
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -20,12 +21,63 @@ logger = logging.getLogger(__name__)
|
|||||||
# Regex patterns
|
# Regex patterns
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Pattern 1: ```bash ... ``` fenced code blocks
|
# Pattern 1: ```bash ... ``` fenced code blocks. The tag may be followed by a
|
||||||
|
# newline (classic form) or by inline JSON args on the same line
|
||||||
|
# (```list_email_accounts {}). The same-line part is captured separately
|
||||||
|
# (group 2) and judged by _fenced_tool_call below — the regex alone only
|
||||||
|
# requires it to start with { or [; anything else after the tag is a Markdown
|
||||||
|
# info string (```python title="example.py") and the fence never matches.
|
||||||
|
# (?![\w-]) keeps the alternation from prefix-matching longer fence tags:
|
||||||
|
# without it, ```python3 would match as tool "python" with content "3\n..."
|
||||||
|
# and execute as code.
|
||||||
_TOOL_BLOCK_RE = re.compile(
|
_TOOL_BLOCK_RE = re.compile(
|
||||||
r"```(" + "|".join(TOOL_TAGS) + r")\s*\n([\s\S]*?)```",
|
r"```(" + "|".join(TOOL_TAGS) + r")(?![\w-])"
|
||||||
|
r"[ \t]*([{\[][^\n]*?)?[ \t]*(?=\r?\n|```)\r?\n?([\s\S]*?)```",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Tags whose fenced content is raw code, not JSON args. Same-line text after
|
||||||
|
# these tags is Markdown fence metadata on a real language (```bash {title=
|
||||||
|
# "setup"}), never inline tool args — only the classic tag-then-newline form
|
||||||
|
# executes for them.
|
||||||
|
_CODE_FENCE_TAGS = frozenset({"bash", "python"})
|
||||||
|
|
||||||
|
|
||||||
|
def _fenced_tool_call(m) -> Optional[Tuple[str, str]]:
|
||||||
|
"""Classify a Pattern-1 fence match: (tag, content) when it is an
|
||||||
|
executable tool call, None when the fence must stay display text.
|
||||||
|
|
||||||
|
Shared by parse_tool_blocks and strip_tool_blocks so the execute and
|
||||||
|
display decisions can never disagree: a fence that doesn't execute is
|
||||||
|
never stripped, and vice versa.
|
||||||
|
|
||||||
|
Same-line text after the tag only counts as inline tool args when the
|
||||||
|
tag's tool takes JSON args (not a code tag) AND the text is valid
|
||||||
|
standalone JSON. ```bash {title="setup"} and ```python {"x": 1} are
|
||||||
|
fence attributes on real languages, and {title="x"} on any tag is
|
||||||
|
metadata, not arguments — all of those stay visible and inert.
|
||||||
|
"""
|
||||||
|
tag = m.group(1).lower()
|
||||||
|
inline = (m.group(2) or "").strip()
|
||||||
|
body = (m.group(3) or "").strip()
|
||||||
|
if not inline:
|
||||||
|
return tag, body
|
||||||
|
if tag in _CODE_FENCE_TAGS:
|
||||||
|
return None
|
||||||
|
# Inline args may continue onto following lines (a JSON object opened on
|
||||||
|
# the tag line); the combined text must parse as JSON or nothing runs.
|
||||||
|
content = f"{inline}\n{body}" if body else inline
|
||||||
|
try:
|
||||||
|
json.loads(content)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return tag, content
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_executed_fence(m) -> str:
|
||||||
|
"""re.sub callback: remove only fences that parse as tool calls."""
|
||||||
|
return "" if _fenced_tool_call(m) is not None else m.group(0)
|
||||||
|
|
||||||
# Pattern 2: [TOOL_CALL] ... [/TOOL_CALL] blocks (some models use this format)
|
# Pattern 2: [TOOL_CALL] ... [/TOOL_CALL] blocks (some models use this format)
|
||||||
# Matches: {tool => "shell", args => {--command "ls -la"}} etc.
|
# Matches: {tool => "shell", args => {--command "ls -la"}} etc.
|
||||||
_TOOL_CALL_RE = re.compile(
|
_TOOL_CALL_RE = re.compile(
|
||||||
@@ -114,6 +166,34 @@ _TOOL_CODE_RE = re.compile(
|
|||||||
_TOOL_CODE_OPEN_RE = re.compile(r"<tool_code>\s*\{", re.IGNORECASE)
|
_TOOL_CODE_OPEN_RE = re.compile(r"<tool_code>\s*\{", re.IGNORECASE)
|
||||||
_TOOL_CODE_CLOSE_RE = re.compile(r"\}\s*</tool_code>", re.IGNORECASE)
|
_TOOL_CODE_CLOSE_RE = re.compile(r"\}\s*</tool_code>", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Pattern 4b: Gemma-style <|tool_call|> call:tool_name{args} <tool_call|>
|
||||||
|
_GEMMA_TOOL_CALL_RE = re.compile(
|
||||||
|
r"<\|?tool_call\|?>\s*call:([\w\d_-]+)\s*(\{[\s\S]*?\})\s*<\|?tool_call\|?>",
|
||||||
|
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
|
# Pattern 5: DeepSeek DSML markup leaking into content. When deepseek
|
||||||
# models can't emit structured tool_calls (e.g. we sent no tool schemas
|
# models can't emit structured tool_calls (e.g. we sent no tool schemas
|
||||||
# that round, or the API didn't parse them), they fall back to raw
|
# that round, or the API didn't parse them), they fall back to raw
|
||||||
@@ -256,6 +336,17 @@ _RAW_WEB_JSON_TOOL_RE = re.compile(
|
|||||||
)
|
)
|
||||||
_RAW_WEB_JSON_ALLOWED_KEYS = {"query", "queries", "time_filter", "freshness", "max_pages"}
|
_RAW_WEB_JSON_ALLOWED_KEYS = {"query", "queries", "time_filter", "freshness", "max_pages"}
|
||||||
|
|
||||||
|
# Narrow rescue for models that ignore native tool calling and print the UI
|
||||||
|
# command as plain text. Keep this intentionally tiny: open-panel is a harmless
|
||||||
|
# frontend event, while broad plain-text parsing of shell/doc/email tools would
|
||||||
|
# be unsafe.
|
||||||
|
_PLAIN_UI_OPEN_PANEL_RE = re.compile(
|
||||||
|
r"(?im)^\s*(?:`{1,3})?\s*ui_control\s+open_panel\s+"
|
||||||
|
r"(documents?|library|gallery|images?|email|inbox|mail|sessions?|chats?|history|"
|
||||||
|
r"notes?|brain|memor(?:y|ies)|skills?|settings|preferences|cookbook|models?)"
|
||||||
|
r"\s*(?:`{1,3})?\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Parsing functions
|
# Parsing functions
|
||||||
@@ -496,6 +587,205 @@ def _parse_raw_web_json_lookup(text: str) -> Optional[tuple[ToolBlock, tuple[int
|
|||||||
return block, (start, start + end)
|
return block, (start, start + end)
|
||||||
return None
|
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]:
|
def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]:
|
||||||
"""Parse a [TOOL_CALL] block into a ToolBlock.
|
"""Parse a [TOOL_CALL] block into a ToolBlock.
|
||||||
|
|
||||||
@@ -780,6 +1070,58 @@ def _parse_tool_code_block(raw: str) -> Optional[ToolBlock]:
|
|||||||
return ToolBlock(tool_name, content.strip())
|
return ToolBlock(tool_name, content.strip())
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
|
||||||
|
"""Parse a Gemma-style call:tool_name{...} block into a ToolBlock."""
|
||||||
|
tool_name = tool_name.strip().lower().replace("-", "_")
|
||||||
|
body = body.strip()
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Replace custom Gemma string delimiters with standard quotes
|
||||||
|
body = body.replace('<|"|>', '"').replace('<|"', '"').replace('"|>', '"')
|
||||||
|
|
||||||
|
# Try standard JSON parsing
|
||||||
|
params = {}
|
||||||
|
try:
|
||||||
|
params = json.loads(body)
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
params = {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Try unquoted keys repair: e.g. {query: "..."} -> {"query": "..."}
|
||||||
|
try:
|
||||||
|
repaired = re.sub(r'([{,]\s*)(\w+)\s*:', r'\1"\2":', body)
|
||||||
|
params = json.loads(repaired)
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
params = {}
|
||||||
|
except Exception:
|
||||||
|
# Simple regex key-value extraction fallback
|
||||||
|
params = {}
|
||||||
|
for m in re.finditer(r'(\w+)\s*:\s*["\']?(.*?)["\']?(?=\s*,\s*\w+\s*:|\s*\})', body):
|
||||||
|
k = m.group(1)
|
||||||
|
v = m.group(2).strip()
|
||||||
|
params[k] = v
|
||||||
|
|
||||||
|
from src.tool_schemas import function_call_to_tool_block
|
||||||
|
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):
|
def _iter_delimited(text, open_re, close_re):
|
||||||
"""Yield ``(match_start, inner_start, inner_end, match_end)`` for each
|
"""Yield ``(match_start, inner_start, inner_end, match_end)`` for each
|
||||||
@@ -923,9 +1265,20 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
# Pattern 1: fenced code blocks (skipped when `skip_fenced` — see docstring).
|
# Pattern 1: fenced code blocks (skipped when `skip_fenced` — see docstring).
|
||||||
if not skip_fenced:
|
if not skip_fenced:
|
||||||
for m in _TOOL_BLOCK_RE.finditer(text):
|
for m in _TOOL_BLOCK_RE.finditer(text):
|
||||||
tag = m.group(1).lower()
|
call = _fenced_tool_call(m)
|
||||||
content = m.group(2).strip()
|
if call is None:
|
||||||
|
continue
|
||||||
|
tag, content = call
|
||||||
if not content:
|
if not content:
|
||||||
|
# An empty fence is still an unambiguous call for the email
|
||||||
|
# tools — ```list_email_accounts``` with no body is a shape
|
||||||
|
# local models really emit for no-arg tools. Dispatch with
|
||||||
|
# empty args and let the tool's own validation answer;
|
||||||
|
# silently dropping the call left models concluding email was
|
||||||
|
# broken. Other tags (bash, python, ...) keep skipping: empty
|
||||||
|
# content is nothing to run.
|
||||||
|
if tag in BUILTIN_EMAIL_TOOLS:
|
||||||
|
blocks.append(ToolBlock(tag, ""))
|
||||||
continue
|
continue
|
||||||
# If a code block's content is an <invoke> XML call (some models wrap
|
# If a code block's content is an <invoke> XML call (some models wrap
|
||||||
# tool calls in ```python or ```xml fences), parse the invoke instead.
|
# tool calls in ```python or ```xml fences), parse the invoke instead.
|
||||||
@@ -1012,12 +1365,45 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
|
|
||||||
|
# Pattern 4b: Gemma-style <|tool_call|> blocks
|
||||||
|
if not blocks:
|
||||||
|
for m in _GEMMA_TOOL_CALL_RE.finditer(text):
|
||||||
|
tool_name = m.group(1)
|
||||||
|
body = m.group(2)
|
||||||
|
block = _parse_gemma_tool_call(tool_name, body)
|
||||||
|
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.
|
# Pattern 6: local text-model web_search call leaked as prose + bare JSON.
|
||||||
if not blocks and not skip_fenced:
|
if not blocks and not skip_fenced:
|
||||||
raw_web_json = _parse_raw_web_json_lookup(text)
|
raw_web_json = _parse_raw_web_json_lookup(text)
|
||||||
if raw_web_json:
|
if raw_web_json:
|
||||||
blocks.append(raw_web_json[0])
|
blocks.append(raw_web_json[0])
|
||||||
|
|
||||||
|
# Pattern 7: plain `ui_control open_panel notes` line. This commonly comes
|
||||||
|
# from weaker native-tool models after reading the tool docs but failing to
|
||||||
|
# emit the actual structured call.
|
||||||
|
if not blocks:
|
||||||
|
m = _PLAIN_UI_OPEN_PANEL_RE.search(text)
|
||||||
|
if m:
|
||||||
|
blocks.append(ToolBlock("ui_control", f"open_panel {m.group(1).lower()}"))
|
||||||
|
|
||||||
return blocks
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
@@ -1037,7 +1423,10 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
|
|||||||
# Normalize DSML first so its markup gets stripped by the <invoke>
|
# Normalize DSML first so its markup gets stripped by the <invoke>
|
||||||
# / <tool_call> removers below instead of leaking to the user.
|
# / <tool_call> removers below instead of leaking to the user.
|
||||||
text = _normalize_dsml(text)
|
text = _normalize_dsml(text)
|
||||||
cleaned = text if skip_fenced else _TOOL_BLOCK_RE.sub('', text)
|
# Keep the executed-vs-illustrative fence distinction (only strip fences
|
||||||
|
# that actually dispatched; leave example fences from native models inert
|
||||||
|
# but visible), then remove [TOOL_CALL]{...}[/TOOL_CALL] markup.
|
||||||
|
cleaned = text if skip_fenced else _TOOL_BLOCK_RE.sub(_strip_executed_fence, text)
|
||||||
# Forward-only removal mirrors parse_tool_blocks: _strip_delimited pairs each
|
# Forward-only removal mirrors parse_tool_blocks: _strip_delimited pairs each
|
||||||
# opener with a later closer and stops when none is reachable, so untrusted
|
# opener with a later closer and stops when none is reachable, so untrusted
|
||||||
# output can't drive the O(n^2) lazy-rescan (ReDoS); see _iter_delimited.
|
# output can't drive the O(n^2) lazy-rescan (ReDoS); see _iter_delimited.
|
||||||
@@ -1046,11 +1435,17 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
|
|||||||
cleaned = _strip_delimited(cleaned, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE)
|
cleaned = _strip_delimited(cleaned, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE)
|
||||||
cleaned = _XML_OPEN_TOOL_CALL_RE.sub('', cleaned)
|
cleaned = _XML_OPEN_TOOL_CALL_RE.sub('', cleaned)
|
||||||
cleaned = _strip_delimited(cleaned, _TOOL_CODE_OPEN_RE, _TOOL_CODE_CLOSE_RE)
|
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:
|
if not skip_fenced:
|
||||||
raw_web_json = _parse_raw_web_json_lookup(cleaned)
|
raw_web_json = _parse_raw_web_json_lookup(cleaned)
|
||||||
if raw_web_json:
|
if raw_web_json:
|
||||||
_, (start, end) = raw_web_json
|
_, (start, end) = raw_web_json
|
||||||
cleaned = cleaned[:start] + cleaned[end:]
|
cleaned = cleaned[:start] + cleaned[end:]
|
||||||
|
cleaned = _PLAIN_UI_OPEN_PANEL_RE.sub("", cleaned)
|
||||||
# Strip bare <invoke> blocks not wrapped in <tool_call>
|
# Strip bare <invoke> blocks not wrapped in <tool_call>
|
||||||
cleaned = _strip_bare_invoke_markup(cleaned)
|
cleaned = _strip_bare_invoke_markup(cleaned)
|
||||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
|
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
|
||||||
|
|||||||
@@ -16,6 +16,39 @@ GUIDE_ONLY_DIRECTIVE = (
|
|||||||
"output they will produce locally."
|
"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 = {
|
_COMMON_TOOL_NAMES = {
|
||||||
"api_call",
|
"api_call",
|
||||||
|
|||||||
+117
-22
@@ -14,9 +14,19 @@ from typing import Optional
|
|||||||
|
|
||||||
from src.agent_tools import ToolBlock, TOOL_TAGS
|
from src.agent_tools import ToolBlock, TOOL_TAGS
|
||||||
from src.tool_parsing import _TOOL_NAME_MAP
|
from src.tool_parsing import _TOOL_NAME_MAP
|
||||||
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# OpenAI-compatible function tool schemas
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -186,7 +196,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "create_document",
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -325,7 +335,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "send_to_session",
|
"name": "send_to_session",
|
||||||
"description": "Send a message to an existing chat and get the model's response. The chat keeps its conversation history.",
|
"description": "Send a new message to an existing live chat and get that chat model's response. Do not use this to retrieve, read, summarize, or inspect old chats; use search_chats or list_sessions for past chat evidence.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -415,7 +425,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "ui_control",
|
"name": "ui_control",
|
||||||
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), open_email_reply (open an email reply draft document; does NOT send), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation). When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
|
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), open_email_reply (open an email reply draft document; DOES NOT send. For 'write/draft a reply saying X', include body with the drafted reply), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation). When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -426,6 +436,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"uid": {"type": "string", "description": "Email UID for open_email_reply"},
|
"uid": {"type": "string", "description": "Email UID for open_email_reply"},
|
||||||
"folder": {"type": "string", "description": "Email folder for open_email_reply (default INBOX)"},
|
"folder": {"type": "string", "description": "Email folder for open_email_reply (default INBOX)"},
|
||||||
"mode": {"type": "string", "description": "Reply draft mode for open_email_reply: reply, reply-all, or ai-reply"},
|
"mode": {"type": "string", "description": "Reply draft mode for open_email_reply: reply, reply-all, or ai-reply"},
|
||||||
|
"body": {"type": "string", "description": "For open_email_reply: reply body to pre-fill. Required whenever the user told you what the reply should say. Opens a draft, does not send."},
|
||||||
"colors": {"type": "object", "description": "For create_theme: the theme colors",
|
"colors": {"type": "object", "description": "For create_theme: the theme colors",
|
||||||
"properties": {
|
"properties": {
|
||||||
"bg": {"type": "string", "description": "Background color (hex, e.g. #1a1a2e)"},
|
"bg": {"type": "string", "description": "Background color (hex, e.g. #1a1a2e)"},
|
||||||
@@ -538,7 +549,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "manage_calendar",
|
"name": "manage_calendar",
|
||||||
"description": "Manage calendar events: list events in a date range, create, update, delete. Each event can carry a tag/category (event_type) and importance level. Resolve relative dates like today/tomorrow against the 'Current date and time' system context, then pass ISO 8601 datetimes in the user's local wall time; for all-day events set all_day=true and pass YYYY-MM-DD. For event reminders/alarms, pass reminder_minutes; the tool creates the Odysseus note reminder, so do not also call manage_notes for the same reminder.",
|
"description": "Manage calendar events: list events in a date range, create, update, delete. Each event can carry a tag/category (event_type) and importance level. Resolve relative dates like today/tomorrow against the 'Current date and time' system context, then pass ISO 8601 datetimes in the user's local wall time; for all-day events set all_day=true and pass YYYY-MM-DD. For event reminders/alarms, pass reminder_minutes; the tool creates the Odysseus note reminder, so do not also call manage_notes for the same reminder. Do not set rrule for single-occurrence requests such as 'next Wednesday only'; use rrule only when the user explicitly wants recurrence.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -554,12 +565,12 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"uid": {"type": "string", "description": "Event UID (for update/delete)"},
|
"uid": {"type": "string", "description": "Event UID (for update/delete)"},
|
||||||
"calendar_href": {"type": "string", "description": "Specific calendar URL (optional; defaults to first calendar)"},
|
"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"},
|
"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."},
|
"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); defaults to +14 days. Prefer end; backend also accepts end_date, range_end, to, dtend, until."},
|
"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."},
|
"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')"},
|
"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'."},
|
"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'."},
|
||||||
"rrule": {"type": "string", "description": "Recurrence rule in iCalendar RRULE format, e.g. 'FREQ=WEEKLY;BYDAY=MO' for weekly on Monday. Use with create_event or update_event."}
|
"rrule": {"type": "string", "description": "Recurrence rule in iCalendar RRULE format, e.g. 'FREQ=WEEKLY;BYDAY=MO' for weekly on Monday. Use with create_event or update_event. For update_event, pass an explicit empty string to remove recurrence and make the event single-occurrence."}
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
}
|
}
|
||||||
@@ -569,14 +580,15 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "manage_notes",
|
"name": "manage_notes",
|
||||||
"description": "Manage notes and checklists (Google Keep-style): list, add, update, delete, toggle_item. IMPORTANT: For to-do lists / checklists, set note_type='checklist' and pass the items as the `checklist_items` array — do NOT serialize them into `content` as plain text. For freeform notes, use note_type='note' and put the body in `content`. `due_date` accepts natural language like 'tomorrow at 9am' (parsed in the user's timezone) and fires a notification — do not also create a calendar event for the same reminder.",
|
"description": "Manage notes and checklists (Google Keep-style): list, view, add, update, delete, toggle_item. Use list/search to find candidate notes, then view with the note id when you need the full body. IMPORTANT: For to-do lists / checklists, set note_type='checklist' and pass the items as the `checklist_items` array — do NOT serialize them into `content` as plain text. For freeform notes, use note_type='note' and put the body in `content`. `due_date` accepts natural language like 'tomorrow at 9am' (parsed in the user's timezone) and fires a notification — do not also create a calendar event for the same reminder.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": {"type": "string",
|
"action": {"type": "string",
|
||||||
"enum": ["list", "add", "update", "delete", "toggle_item"],
|
"enum": ["list", "search", "view", "add", "update", "delete", "toggle_item"],
|
||||||
"description": "The action to perform"},
|
"description": "The action to perform"},
|
||||||
"id": {"type": "string", "description": "Note id (for update/delete/toggle_item); 8-char prefix is fine"},
|
"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)"},
|
"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."},
|
"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"],
|
"note_type": {"type": "string", "enum": ["note", "checklist"],
|
||||||
@@ -1023,7 +1035,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "manage_contact",
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1031,9 +1043,9 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"description": "list = show all contacts (with uids); add = create; update = edit by uid; delete = remove by uid."},
|
"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)."},
|
"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)."},
|
"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)."},
|
"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 (for update; first is primary)."},
|
"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 (for update)."},
|
"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."},
|
"address": {"type": "string", "description": "Postal/mailing address as a single human-readable string."},
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
@@ -1106,7 +1118,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "reply_to_email",
|
"name": "reply_to_email",
|
||||||
"description": "SEND a reply email immediately by UID. Do not use this when the user asks to open/start a reply window or draft; use ui_control action=open_email_reply instead. For follow-up 'reply ...' requests where the user clearly wants to send now, use the exact UID from the latest read_email/list_emails result; never invent UID 1. Automatically threads with In-Reply-To/References headers.",
|
"description": "SEND a reply email immediately by UID. Do not use this when the user asks to write/draft/open/start a reply; use ui_control action=open_email_reply with body instead so the user can review. Only use when the user explicitly says to send now. Use the exact UID from the latest read_email/list_emails result; never invent UID 1. Automatically threads with In-Reply-To/References headers.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1210,38 +1222,113 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
# Converter: native function call -> ToolBlock
|
# Converter: native function call -> ToolBlock
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _decode_loose_json_string(value: str) -> str:
|
||||||
|
"""Decode common JSON string escapes without requiring inner quotes to be escaped."""
|
||||||
|
out = []
|
||||||
|
i = 0
|
||||||
|
while i < len(value):
|
||||||
|
ch = value[i]
|
||||||
|
if ch != "\\" or i + 1 >= len(value):
|
||||||
|
out.append(ch)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
nxt = value[i + 1]
|
||||||
|
if nxt == "n":
|
||||||
|
out.append("\n")
|
||||||
|
elif nxt == "r":
|
||||||
|
out.append("\r")
|
||||||
|
elif nxt == "t":
|
||||||
|
out.append("\t")
|
||||||
|
elif nxt == "b":
|
||||||
|
out.append("\b")
|
||||||
|
elif nxt == "f":
|
||||||
|
out.append("\f")
|
||||||
|
elif nxt in ('"', "\\", "/"):
|
||||||
|
out.append(nxt)
|
||||||
|
elif nxt == "u" and i + 5 < len(value):
|
||||||
|
try:
|
||||||
|
out.append(chr(int(value[i + 2:i + 6], 16)))
|
||||||
|
i += 4
|
||||||
|
except ValueError:
|
||||||
|
out.append("\\" + nxt)
|
||||||
|
else:
|
||||||
|
out.append("\\" + nxt)
|
||||||
|
i += 2
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_document_function_args(tool_type: str, arguments: str) -> Optional[dict]:
|
||||||
|
"""Salvage obvious malformed document tool args from local model wrappers.
|
||||||
|
|
||||||
|
The doc LoRA sometimes emits the right native tool call but puts raw quotes
|
||||||
|
inside the document text, making the surrounding JSON invalid. Treat that as
|
||||||
|
a wrapper parse failure, not a semantic tool-choice failure.
|
||||||
|
"""
|
||||||
|
if tool_type != "update_document" or not isinstance(arguments, str):
|
||||||
|
return None
|
||||||
|
raw = arguments.strip()
|
||||||
|
if not raw.startswith("{") or not raw.endswith("}"):
|
||||||
|
return None
|
||||||
|
for key in ("content", "conten"):
|
||||||
|
marker = f'"{key}"'
|
||||||
|
key_pos = raw.find(marker)
|
||||||
|
if key_pos < 0:
|
||||||
|
continue
|
||||||
|
colon_pos = raw.find(":", key_pos + len(marker))
|
||||||
|
if colon_pos < 0:
|
||||||
|
continue
|
||||||
|
first_quote = raw.find('"', colon_pos + 1)
|
||||||
|
if first_quote < 0:
|
||||||
|
continue
|
||||||
|
close_brace = raw.rfind("}")
|
||||||
|
last_quote = raw.rfind('"', first_quote + 1, close_brace)
|
||||||
|
if last_quote <= first_quote:
|
||||||
|
continue
|
||||||
|
content = _decode_loose_json_string(raw[first_quote + 1:last_quote])
|
||||||
|
return {"content": content}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock]:
|
def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock]:
|
||||||
"""Convert a native function call into a ToolBlock for the existing execution pipeline."""
|
"""Convert a native function call into a ToolBlock for the existing execution pipeline."""
|
||||||
|
tool_type = _TOOL_NAME_MAP.get(name, name)
|
||||||
try:
|
try:
|
||||||
if not arguments or (isinstance(arguments, str) and not arguments.strip()):
|
if not arguments or (isinstance(arguments, str) and not arguments.strip()):
|
||||||
args = {}
|
args = {}
|
||||||
else:
|
else:
|
||||||
args = json.loads(arguments) if isinstance(arguments, str) else arguments
|
args = json.loads(arguments) if isinstance(arguments, str) else arguments
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
args = _repair_document_function_args(tool_type, arguments)
|
||||||
|
if args is not None:
|
||||||
|
logger.warning(f"Repaired malformed document function call arguments for {name}")
|
||||||
|
else:
|
||||||
logger.error(f"Failed to parse function call arguments for {name}: {arguments}")
|
logger.error(f"Failed to parse function call arguments for {name}: {arguments}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
tool_type = _TOOL_NAME_MAP.get(name, name)
|
|
||||||
_BUILTIN_EMAIL_TOOLS = {"list_email_accounts", "send_email", "list_emails", "read_email", "reply_to_email",
|
|
||||||
"archive_email", "delete_email", "mark_email_read", "bulk_email", "download_attachment"}
|
|
||||||
|
|
||||||
# Some models emit valid JSON that isn't an object (e.g. a bare array
|
# Some models emit valid JSON that isn't an object (e.g. a bare array
|
||||||
# ["ls -la"], string, or number) as function arguments. Most local tools keep
|
# ["ls -la"], string, or number) as function arguments. Most local tools keep
|
||||||
# the legacy empty-object coercion for stream robustness, but email MCP tools
|
# the legacy empty-object coercion for stream robustness, but email MCP tools
|
||||||
# must fail closed so a malformed call cannot read the default mailbox.
|
# must fail closed so a malformed call cannot read the default mailbox.
|
||||||
|
# Uses the shared BUILTIN_EMAIL_TOOLS (single source of truth) so the
|
||||||
|
# fail-closed set can't drift from the dispatch/blocklist sets.
|
||||||
if not isinstance(args, dict):
|
if not isinstance(args, dict):
|
||||||
if tool_type.startswith("mcp__email__") or name in _BUILTIN_EMAIL_TOOLS:
|
if tool_type.startswith("mcp__email__") or name in BUILTIN_EMAIL_TOOLS:
|
||||||
logger.warning(f"Non-object email function call arguments for {name}: {args!r}; rejecting")
|
logger.warning(f"Non-object email function call arguments for {name}: {args!r}; rejecting")
|
||||||
return None
|
return None
|
||||||
logger.warning(f"Non-object function call arguments for {name}: {args!r}; treating as empty")
|
logger.warning(f"Non-object function call arguments for {name}: {args!r}; treating as empty")
|
||||||
args = {}
|
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)
|
# Allow MCP tools through (namespaced as mcp__serverid__toolname)
|
||||||
if tool_type.startswith("mcp__"):
|
if tool_type.startswith("mcp__"):
|
||||||
content = json.dumps(args) if args else "{}"
|
content = json.dumps(args) if args else "{}"
|
||||||
return ToolBlock(tool_type, content)
|
return ToolBlock(tool_type, content)
|
||||||
# Email tools are implemented as MCP — route them to email
|
# Email tools are implemented as MCP — route them to email
|
||||||
if name in _BUILTIN_EMAIL_TOOLS:
|
if name in BUILTIN_EMAIL_TOOLS:
|
||||||
return ToolBlock(f"mcp__email__{name}", json.dumps(args) if args else "{}")
|
return ToolBlock(f"mcp__email__{name}", json.dumps(args) if args else "{}")
|
||||||
if tool_type not in TOOL_TAGS:
|
if tool_type not in TOOL_TAGS:
|
||||||
logger.warning(f"Unknown function call: {name}")
|
logger.warning(f"Unknown function call: {name}")
|
||||||
@@ -1343,15 +1430,20 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
|||||||
elif tool_type == "manage_memory":
|
elif tool_type == "manage_memory":
|
||||||
action = args.get("action", "")
|
action = args.get("action", "")
|
||||||
if action == "add":
|
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"):
|
if args.get("category"):
|
||||||
content += "\n" + args["category"]
|
content += "\n" + args["category"]
|
||||||
|
elif args.get("key"):
|
||||||
|
content += "\n" + str(args["key"])
|
||||||
elif action == "edit":
|
elif action == "edit":
|
||||||
content = "edit\n" + args.get("memory_id", "") + "\n" + args.get("text", "")
|
content = "edit\n" + args.get("memory_id", "") + "\n" + args.get("text", "")
|
||||||
elif action == "delete":
|
elif action == "delete":
|
||||||
content = "delete\n" + args.get("memory_id", "")
|
content = "delete\n" + args.get("memory_id", "")
|
||||||
elif action == "search":
|
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":
|
elif action == "list":
|
||||||
content = "list"
|
content = "list"
|
||||||
if args.get("category"):
|
if args.get("category"):
|
||||||
@@ -1373,6 +1465,9 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
|||||||
folder = args.get("folder") or value or "INBOX"
|
folder = args.get("folder") or value or "INBOX"
|
||||||
mode = args.get("mode") or "reply"
|
mode = args.get("mode") or "reply"
|
||||||
content = f"open_email_reply {uid} {folder} {mode}"
|
content = f"open_email_reply {uid} {folder} {mode}"
|
||||||
|
body = args.get("body") or args.get("extra") or args.get("content") or ""
|
||||||
|
if body:
|
||||||
|
content += f" {body}"
|
||||||
elif action == "set_mode":
|
elif action == "set_mode":
|
||||||
content = f"set_mode {value or name}"
|
content = f"set_mode {value or name}"
|
||||||
elif action == "switch_model":
|
elif action == "switch_model":
|
||||||
|
|||||||
+70
-7
@@ -8,10 +8,36 @@ from typing import Optional, Set
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Every tool exposed by the built-in email MCP server
|
||||||
|
# (mcp_servers/email_server.py). Single source of truth: the fence tags
|
||||||
|
# (TOOL_TAGS), bare-name dispatch (tool_execution), native-call mapping
|
||||||
|
# (tool_schemas), and the non-admin blocklist below all derive from this set,
|
||||||
|
# so a tool added to the email server can't become reachable under its bare
|
||||||
|
# name without also being blocked for non-admins.
|
||||||
|
BUILTIN_EMAIL_TOOLS = frozenset({
|
||||||
|
"list_email_accounts",
|
||||||
|
"list_emails",
|
||||||
|
"read_email",
|
||||||
|
"search_emails",
|
||||||
|
"send_email",
|
||||||
|
"reply_to_email",
|
||||||
|
"draft_email",
|
||||||
|
"draft_email_reply",
|
||||||
|
"ai_draft_email_reply",
|
||||||
|
"archive_email",
|
||||||
|
"delete_email",
|
||||||
|
"mark_email_read",
|
||||||
|
"bulk_email",
|
||||||
|
"download_attachment",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# Tools regular/public users must not execute directly. These either expose
|
# Tools regular/public users must not execute directly. These either expose
|
||||||
# server/runtime access, sensitive user data, external messaging, persistent
|
# server/runtime access, sensitive user data, external messaging, persistent
|
||||||
# state changes, or generic loopback/integration surfaces.
|
# state changes, or generic loopback/integration surfaces. All email tools are
|
||||||
NON_ADMIN_BLOCKED_TOOLS = {
|
# included (SECURITY.md: email/MCP capabilities are privileged admin
|
||||||
|
# functionality).
|
||||||
|
NON_ADMIN_BLOCKED_TOOLS = BUILTIN_EMAIL_TOOLS | {
|
||||||
"bash",
|
"bash",
|
||||||
"python",
|
"python",
|
||||||
"manage_bg_jobs",
|
"manage_bg_jobs",
|
||||||
@@ -34,10 +60,6 @@ NON_ADMIN_BLOCKED_TOOLS = {
|
|||||||
"manage_settings",
|
"manage_settings",
|
||||||
"api_call",
|
"api_call",
|
||||||
"app_api",
|
"app_api",
|
||||||
"send_email",
|
|
||||||
"reply_to_email",
|
|
||||||
"list_emails",
|
|
||||||
"read_email",
|
|
||||||
"resolve_contact",
|
"resolve_contact",
|
||||||
"manage_contact",
|
"manage_contact",
|
||||||
"manage_calendar",
|
"manage_calendar",
|
||||||
@@ -74,8 +96,20 @@ PLAN_MODE_READONLY_TOOLS = {
|
|||||||
"search_chats",
|
"search_chats",
|
||||||
"list_models",
|
"list_models",
|
||||||
"list_sessions",
|
"list_sessions",
|
||||||
|
# Read-only email tools. list_email_accounts must be here because the
|
||||||
|
# bare/qualified alias gate in execute_tool_block works both ways: it has
|
||||||
|
# a native function schema, so plan mode's schema-derived bare denylist
|
||||||
|
# contains it — and without this allowlist entry that bare entry would
|
||||||
|
# also block the qualified mcp__email__list_email_accounts call that the
|
||||||
|
# MCP read-only filter deliberately allows.
|
||||||
|
"list_email_accounts",
|
||||||
"list_emails",
|
"list_emails",
|
||||||
"read_email",
|
"read_email",
|
||||||
|
# Explicitly read-only rather than allowed-by-omission: this PR makes
|
||||||
|
# every BUILTIN_EMAIL_TOOLS name fence-taggable, so each one must be
|
||||||
|
# classified — see the plan-mode partition test in
|
||||||
|
# tests/test_email_registry_sync.py.
|
||||||
|
"search_emails",
|
||||||
"list_served_models",
|
"list_served_models",
|
||||||
"list_downloads",
|
"list_downloads",
|
||||||
"list_cached_models",
|
"list_cached_models",
|
||||||
@@ -109,7 +143,14 @@ _PLAN_MODE_KNOWN_MUTATORS = {
|
|||||||
"manage_webhooks", "manage_tokens", "manage_settings", "manage_contact",
|
"manage_webhooks", "manage_tokens", "manage_settings", "manage_contact",
|
||||||
"manage_calendar", "api_call", "app_api", "ui_control",
|
"manage_calendar", "api_call", "app_api", "ui_control",
|
||||||
"send_email", "reply_to_email", "bulk_email", "delete_email",
|
"send_email", "reply_to_email", "bulk_email", "delete_email",
|
||||||
"archive_email", "mark_email_read", "download_model", "serve_model",
|
"archive_email", "mark_email_read",
|
||||||
|
# The draft tools create documents and download_attachment writes to
|
||||||
|
# disk — mutating. They have no native schemas (yet), so without these
|
||||||
|
# static entries plan-mode safety for their bare fence tags would depend
|
||||||
|
# entirely on the MCP read-only inventory being present and current.
|
||||||
|
"draft_email", "draft_email_reply", "ai_draft_email_reply",
|
||||||
|
"download_attachment",
|
||||||
|
"download_model", "serve_model",
|
||||||
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
|
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
|
||||||
"generate_image", "edit_image", "trigger_research", "manage_research",
|
"generate_image", "edit_image", "trigger_research", "manage_research",
|
||||||
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
||||||
@@ -151,6 +192,28 @@ def plan_mode_disabled_tools() -> Set[str]:
|
|||||||
return (all_names | _PLAN_MODE_KNOWN_MUTATORS) - PLAN_MODE_READONLY_TOOLS
|
return (all_names | _PLAN_MODE_KNOWN_MUTATORS) - PLAN_MODE_READONLY_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
def email_tool_policy_names(tool_name: str) -> frozenset:
|
||||||
|
"""All policy-equivalent spellings of a tool name.
|
||||||
|
|
||||||
|
A bare built-in email tool name and its MCP-qualified mcp__email__<name>
|
||||||
|
form dispatch to the same email server tool, but policy sources spell
|
||||||
|
them either way — plan mode and the MCP settings toggle write qualified
|
||||||
|
names into denylists, chat-level toggles write bare ones. Every gate must
|
||||||
|
match against the full alias set, or a call in one spelling slips past a
|
||||||
|
denylist entry written in the other. Non-email names alias only to
|
||||||
|
themselves.
|
||||||
|
"""
|
||||||
|
if not isinstance(tool_name, str):
|
||||||
|
return frozenset((tool_name,))
|
||||||
|
if tool_name in BUILTIN_EMAIL_TOOLS:
|
||||||
|
return frozenset((tool_name, f"mcp__email__{tool_name}"))
|
||||||
|
if tool_name.startswith("mcp__email__"):
|
||||||
|
bare = tool_name[len("mcp__email__"):]
|
||||||
|
if bare in BUILTIN_EMAIL_TOOLS:
|
||||||
|
return frozenset((tool_name, bare))
|
||||||
|
return frozenset((tool_name,))
|
||||||
|
|
||||||
|
|
||||||
def is_public_blocked_tool(tool_name: Optional[str]) -> bool:
|
def is_public_blocked_tool(tool_name: Optional[str]) -> bool:
|
||||||
"""Return True when a non-admin/public user must not execute this tool.
|
"""Return True when a non-admin/public user must not execute this tool.
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ def _parse_tool_args(content):
|
|||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
try:
|
try:
|
||||||
args = json.loads(content) if content.strip() else {}
|
args = json.loads(content) if content.strip() else {}
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
args = {}
|
||||||
except (json.JSONDecodeError, TypeError) as e:
|
except (json.JSONDecodeError, TypeError) as e:
|
||||||
raise ValueError(str(e))
|
raise ValueError(str(e))
|
||||||
elif isinstance(content, dict):
|
elif isinstance(content, dict):
|
||||||
|
|||||||
+18
-2
@@ -208,11 +208,20 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
elif action == "list_events":
|
elif action == "list_events":
|
||||||
try:
|
try:
|
||||||
start_raw = _first_nonempty_arg(
|
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_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:
|
if start_raw:
|
||||||
start_dt = _parse_dt(start_raw)
|
start_dt = _parse_dt(start_raw)
|
||||||
else:
|
else:
|
||||||
@@ -254,6 +263,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
"calendar_href": ev.calendar_id,
|
"calendar_href": ev.calendar_id,
|
||||||
"event_type": ev.event_type or "",
|
"event_type": ev.event_type or "",
|
||||||
"importance": ev.importance or "normal",
|
"importance": ev.importance or "normal",
|
||||||
|
"rrule": ev.rrule or "",
|
||||||
})
|
})
|
||||||
if not events:
|
if not events:
|
||||||
response_text = f"No events between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}."
|
response_text = f"No events between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}."
|
||||||
@@ -268,6 +278,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
line += f" #{ev['event_type']}"
|
line += f" #{ev['event_type']}"
|
||||||
if ev.get("importance") and ev["importance"] != "normal":
|
if ev.get("importance") and ev["importance"] != "normal":
|
||||||
line += f" !{ev['importance']}"
|
line += f" !{ev['importance']}"
|
||||||
|
if ev.get("rrule"):
|
||||||
|
line += f" repeats({ev['rrule']})"
|
||||||
if ev.get("location"):
|
if ev.get("location"):
|
||||||
line += f" @ {ev['location']}"
|
line += f" @ {ev['location']}"
|
||||||
if ev.get("calendar"):
|
if ev.get("calendar"):
|
||||||
@@ -480,6 +492,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
ev.event_type = _tag or None
|
ev.event_type = _tag or None
|
||||||
if args.get("importance") is not None:
|
if args.get("importance") is not None:
|
||||||
ev.importance = args["importance"]
|
ev.importance = args["importance"]
|
||||||
|
if args.get("rrule") is not None:
|
||||||
|
ev.rrule = args.get("rrule") or ""
|
||||||
|
elif str(args.get("repeat") or "").strip().lower() in {"none", "no", "off", "false", "single"}:
|
||||||
|
ev.rrule = ""
|
||||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||||
if is_caldav:
|
if is_caldav:
|
||||||
ev.caldav_sync_pending = "update"
|
ev.caldav_sync_pending = "update"
|
||||||
|
|||||||
+23
-10
@@ -108,16 +108,28 @@ async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
|
|
||||||
if action == "add":
|
if action == "add":
|
||||||
email = (args.get("email") or "").strip()
|
email = (args.get("email") or "").strip()
|
||||||
if not email:
|
phones = [str(p or "").strip() for p in (args.get("phones") or []) if str(p or "").strip()]
|
||||||
return {"error": "email is required for add", "exit_code": 1}
|
phone = (args.get("phone") or "").strip()
|
||||||
name = (args.get("name") or "").strip() or email.split("@")[0]
|
if phone and phone not in phones:
|
||||||
# Dedupe by email (same as the /add route).
|
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)
|
existing = await asyncio.to_thread(cc._fetch_contacts)
|
||||||
for c in existing:
|
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}
|
return {"output": f"{email} is already a contact ({c.get('name','')}).", "exit_code": 0}
|
||||||
ok = await asyncio.to_thread(cc._create_contact, name, email)
|
if phones and any(p in (c.get("phones") or []) for p in phones):
|
||||||
return {"output": f"{'Added' if ok else 'Failed to add'} {name} <{email}>.", "exit_code": 0 if ok else 1}
|
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"):
|
if action in ("update", "edit"):
|
||||||
uid = (args.get("uid") or "").strip()
|
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 = [args["email"]]
|
||||||
emails = [e.strip() for e in (emails or []) if e and e.strip()]
|
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()]
|
phones = [p.strip() for p in (args.get("phones") or []) if p and p.strip()]
|
||||||
if not name and not emails:
|
address = (args.get("address") or "").strip()
|
||||||
return {"error": "Provide a name or emails to update", "exit_code": 1}
|
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:
|
if not name and emails:
|
||||||
name = emails[0].split("@")[0]
|
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}
|
return {"output": "Contact updated." if ok else "Update failed.", "exit_code": 0 if ok else 1}
|
||||||
|
|
||||||
if action == "delete":
|
if action == "delete":
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ async def _cookbook_register_task(
|
|||||||
_MODEL_PROCESS_PATTERNS = [
|
_MODEL_PROCESS_PATTERNS = [
|
||||||
("vLLM", ["vllm.entrypoints", "vllm serve", "/vllm/", "vllm-openai"]),
|
("vLLM", ["vllm.entrypoints", "vllm serve", "/vllm/", "vllm-openai"]),
|
||||||
("SGLang", ["sglang.launch_server", "sglang/launch_server"]),
|
("SGLang", ["sglang.launch_server", "sglang/launch_server"]),
|
||||||
|
("MLX", ["mlx_lm.server", "mlx-lm"]),
|
||||||
("llama.cpp", ["llama-server", "llama_cpp_server", "llamacppserver"]),
|
("llama.cpp", ["llama-server", "llama_cpp_server", "llamacppserver"]),
|
||||||
("Ollama", ["ollama serve", "ollama runner", "/ollama "]),
|
("Ollama", ["ollama serve", "ollama runner", "/ollama "]),
|
||||||
("ComfyUI", ["comfyui/main.py", "/ComfyUI/main.py", "ComfyUI"]),
|
("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 = ""
|
hint = ""
|
||||||
if isinstance(err_msg, str) and "cmd" in err_msg.lower():
|
if isinstance(err_msg, str) and "cmd" in err_msg.lower():
|
||||||
hint = (" — the cmd must START with an allowlisted binary "
|
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 `&&`. "
|
"Do NOT prefix with `cd …`, `source …`, or chain with `&&`. "
|
||||||
"env_prefix (e.g. `source ~/qwen35-env/bin/activate`) is added "
|
"env_prefix (e.g. `source ~/qwen35-env/bin/activate`) is added "
|
||||||
"automatically from the host's saved venv settings.")
|
"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:
|
if not merged:
|
||||||
return {
|
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,
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+67
-16
@@ -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
|
# Action aliases — match what models actually emit. `create` is the most
|
||||||
# common alternative to `add`. Hyphenated forms also accepted.
|
# 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 = {
|
_NOTE_ACTION_ALIASES = {
|
||||||
"create": "add",
|
"create": "add",
|
||||||
"new": "add",
|
"new": "add",
|
||||||
@@ -60,18 +61,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
q = q.filter(Note.owner == owner)
|
q = q.filter(Note.owner == owner)
|
||||||
return q.first()
|
return q.first()
|
||||||
|
|
||||||
try:
|
def _format_note_list(notes) -> str:
|
||||||
if action == "list":
|
|
||||||
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"])
|
|
||||||
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 not notes:
|
|
||||||
return {"response": "No notes found.", "exit_code": 0}
|
|
||||||
lines = []
|
lines = []
|
||||||
for n in notes:
|
for n in notes:
|
||||||
pin = " [PINNED]" if n.pinned else ""
|
pin = " [PINNED]" if n.pinned else ""
|
||||||
@@ -90,7 +80,49 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
elif n.content:
|
elif n.content:
|
||||||
snippet = n.content[:80].replace("\n", " ")
|
snippet = n.content[:80].replace("\n", " ")
|
||||||
lines.append(f" {snippet}")
|
lines.append(f" {snippet}")
|
||||||
return {"results": "\n".join(lines)}
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if action in ("list", "search", "find"):
|
||||||
|
q = db.query(Note)
|
||||||
|
if owner is not None:
|
||||||
|
q = q.filter(Note.owner == owner)
|
||||||
|
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}
|
||||||
|
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":
|
elif action == "add":
|
||||||
# Accept the various field names models emit: `text` is the most
|
# 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
|
# `new Date()` resolves the right absolute moment regardless of
|
||||||
# where the user is.
|
# where the user is.
|
||||||
due_raw = args.get("due_date")
|
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
|
due_iso = None
|
||||||
if due_raw:
|
if due_raw:
|
||||||
try:
|
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
|
# link with no target, leaving the user with a click that
|
||||||
# did nothing and uncertainty about whether the note was made.
|
# did nothing and uncertainty about whether the note was made.
|
||||||
return {
|
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_id": note.id,
|
||||||
"note_title": title or "",
|
"note_title": title or "",
|
||||||
"open_url": f"/#open=notes¬e={note.id}",
|
"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}
|
return {"response": f"Item '{items[index].get('text', '')}' marked {mark}", "exit_code": 0}
|
||||||
|
|
||||||
else:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"manage_notes error: {e}")
|
logger.error(f"manage_notes error: {e}")
|
||||||
return {"error": str(e), "exit_code": 1}
|
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"]
|
lines = [f"Found {len(seen_sessions)} session(s) matching \"{query}\":\n"]
|
||||||
for sid, result in seen_sessions.items():
|
for sid, result in seen_sessions.items():
|
||||||
lines.append(f"- **{result.session_name}** (#{sid})")
|
lines.append(f"- [**{result.session_name}**](#session-{sid})")
|
||||||
lines.append(f" Link: [Open chat](#{sid})")
|
lines.append(f" Open: [Open chat](#session-{sid})")
|
||||||
lines.append(f" Match ({result.role}): {result.content_snippet}")
|
lines.append(f" Match ({result.role}): {result.content_snippet}")
|
||||||
if result.context_before:
|
if result.context_before:
|
||||||
before = result.context_before[-1]
|
before = result.context_before[-1]
|
||||||
|
|||||||
@@ -538,6 +538,11 @@ _APP_API_BLOCKLIST_METHOD_PATH = (
|
|||||||
# sidebar surfaces the session. Raw start works but the agent
|
# sidebar surfaces the session. Raw start works but the agent
|
||||||
# fumbles the payload + the session doesn't reliably show up.
|
# fumbles the payload + the session doesn't reliably show up.
|
||||||
("POST", "/api/research/start"),
|
("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-
|
# Use the named tools — they handle owner attribution, natural-
|
||||||
# language due_date parsing, timezone, dedup, and tag/category
|
# language due_date parsing, timezone, dedup, and tag/category
|
||||||
# normalization. Hitting the raw endpoint via app_api saves a
|
# 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}
|
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:
|
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}
|
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:
|
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}
|
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:
|
if "/api/calendar/events" in path:
|
||||||
|
|||||||
+152
-5
@@ -7,10 +7,12 @@ import ipaddress
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import ssl
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpcore
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from src.database import SessionLocal, Webhook
|
from src.database import SessionLocal, Webhook
|
||||||
@@ -125,6 +127,128 @@ def validate_webhook_url(url: str) -> str:
|
|||||||
return url
|
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:
|
def validate_events(events_str: str) -> str:
|
||||||
"""Validate comma-separated event names. Returns cleaned string."""
|
"""Validate comma-separated event names. Returns cleaned string."""
|
||||||
events = [e.strip() for e in events_str.split(",") if e.strip()]
|
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:
|
class WebhookManager:
|
||||||
def __init__(self, api_key_manager=None):
|
def __init__(self, api_key_manager=None):
|
||||||
# Disable redirects to prevent SSRF via redirect chains
|
# No shared client: each delivery builds a short-lived client whose
|
||||||
self._client = httpx.AsyncClient(timeout=10, follow_redirects=False)
|
# 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._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
self._api_key_manager = api_key_manager
|
self._api_key_manager = api_key_manager
|
||||||
# Strong references to in-flight fire-and-forget tasks. asyncio only
|
# Strong references to in-flight fire-and-forget tasks. asyncio only
|
||||||
@@ -262,11 +389,28 @@ class WebhookManager:
|
|||||||
decrypted = self._decrypt_secret(encrypted_secret)
|
decrypted = self._decrypt_secret(encrypted_secret)
|
||||||
await self._deliver(webhook_id, url, decrypted, "webhook.test", {"message": "Test ping from Odysseus"})
|
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):
|
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)."""
|
"""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:
|
try:
|
||||||
validate_webhook_url(url)
|
validate_webhook_url(url)
|
||||||
|
pinned_ips = _validated_public_ips(url)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Webhook {webhook_id} has invalid URL, skipping: {e}")
|
logger.warning(f"Webhook {webhook_id} has invalid URL, skipping: {e}")
|
||||||
return
|
return
|
||||||
@@ -283,7 +427,7 @@ class WebhookManager:
|
|||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
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({
|
db.query(Webhook).filter(Webhook.id == webhook_id).update({
|
||||||
"last_triggered_at": _utcnow(),
|
"last_triggered_at": _utcnow(),
|
||||||
"last_status_code": resp.status_code,
|
"last_status_code": resp.status_code,
|
||||||
@@ -305,4 +449,7 @@ class WebhookManager:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
async def close(self):
|
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
|
||||||
|
|||||||
+386
-82
@@ -22,7 +22,7 @@ import memoryModule from './js/memory.js';
|
|||||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||||
import censorModule from './js/censor.js';
|
import censorModule from './js/censor.js';
|
||||||
import galleryModule from './js/gallery.js';
|
import galleryModule from './js/gallery.js';
|
||||||
import tasksModule from './js/tasks.js';
|
import tasksModule from './js/tasks.js?v=20260630tasksactivity';
|
||||||
import calendarModule from './js/calendar.js';
|
import calendarModule from './js/calendar.js';
|
||||||
import notesModule from './js/notes.js';
|
import notesModule from './js/notes.js';
|
||||||
import adminModule from './js/admin.js';
|
import adminModule from './js/admin.js';
|
||||||
@@ -39,7 +39,7 @@ import themeModule from './js/theme.js';
|
|||||||
// unversioned so this can't recur.
|
// unversioned so this can't recur.
|
||||||
import cookbookModule from './js/cookbook.js';
|
import cookbookModule from './js/cookbook.js';
|
||||||
import groupModule from './js/group.js';
|
import groupModule from './js/group.js';
|
||||||
import * as researchPanelModule from './js/research/panel.js';
|
import * as researchPanelModule from './js/research/panel.js?v=20260630researchthumb';
|
||||||
import ttsModule from './js/tts-ai.js';
|
import ttsModule from './js/tts-ai.js';
|
||||||
import spinnerModule from './js/spinner.js';
|
import spinnerModule from './js/spinner.js';
|
||||||
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js';
|
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js';
|
||||||
@@ -53,6 +53,138 @@ window.uiModule = uiModule;
|
|||||||
window.adminModule = adminModule;
|
window.adminModule = adminModule;
|
||||||
window.cookbookModule = cookbookModule;
|
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;
|
||||||
|
const send = (force = false) => {
|
||||||
|
if (document.visibilityState === 'hidden') return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && now - lastSent < minGapMs) return;
|
||||||
|
lastSent = now;
|
||||||
|
try {
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
const body = new Blob(['{}'], { type: 'application/json' });
|
||||||
|
if (navigator.sendBeacon('/api/activity/heartbeat', body)) return;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
fetch('/api/activity/heartbeat', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
keepalive: true,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
send(true);
|
||||||
|
window.addEventListener('focus', () => send(true));
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState !== 'hidden') send(true);
|
||||||
|
});
|
||||||
|
['pointerdown', 'keydown', 'touchstart', 'scroll'].forEach(type => {
|
||||||
|
window.addEventListener(type, () => send(false), { passive: true, capture: true });
|
||||||
|
});
|
||||||
|
setInterval(() => send(false), 15000);
|
||||||
|
}
|
||||||
|
initForegroundActivityHeartbeat();
|
||||||
|
|
||||||
|
function initRailHoverLabels() {
|
||||||
|
const labels = {
|
||||||
|
'rail-search-btn': 'Search',
|
||||||
|
'rail-new-session': 'New',
|
||||||
|
'rail-delete-session': 'Delete',
|
||||||
|
'rail-chats': 'Chat',
|
||||||
|
'rail-documents': 'Docs',
|
||||||
|
'rail-calendar': 'Calendar',
|
||||||
|
'rail-compare': 'Compare',
|
||||||
|
'rail-cookbook': 'Cookbook',
|
||||||
|
'rail-research': 'Research',
|
||||||
|
'rail-email': 'Email',
|
||||||
|
'rail-gallery': 'Gallery',
|
||||||
|
'rail-archive': 'Library',
|
||||||
|
'rail-memory': 'Brain',
|
||||||
|
'rail-notes': 'Notes',
|
||||||
|
'rail-tasks': 'Tasks',
|
||||||
|
'rail-theme': 'Theme',
|
||||||
|
'rail-settings': 'Settings',
|
||||||
|
};
|
||||||
|
document.querySelectorAll('#icon-rail .icon-rail-btn').forEach(btn => {
|
||||||
|
if (btn.querySelector('.rail-hover-label')) return;
|
||||||
|
const label = labels[btn.id] || btn.getAttribute('aria-label') || btn.getAttribute('title') || '';
|
||||||
|
if (!label) return;
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'rail-hover-label';
|
||||||
|
span.textContent = String(label).replace(/\s*\([^)]*\)\s*/g, '').trim();
|
||||||
|
btn.appendChild(span);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect to login on 401 from any fetch
|
// Redirect to login on 401 from any fetch
|
||||||
const _origFetch = window.fetch;
|
const _origFetch = window.fetch;
|
||||||
window.fetch = async function(...args) {
|
window.fetch = async function(...args) {
|
||||||
@@ -120,6 +252,50 @@ async function _createDirectChatFromPreferredModel() {
|
|||||||
return false;
|
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
|
// EVENT LISTENERS INITIALIZATION
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -130,9 +306,9 @@ function initializeEventListeners() {
|
|||||||
// File attachments (inside overflow menu)
|
// File attachments (inside overflow menu)
|
||||||
const _overflowAttach = el('overflow-attach-btn');
|
const _overflowAttach = el('overflow-attach-btn');
|
||||||
if (_overflowAttach) _overflowAttach.addEventListener('click', fileHandlerModule.openPicker);
|
if (_overflowAttach) _overflowAttach.addEventListener('click', fileHandlerModule.openPicker);
|
||||||
el('file-input').addEventListener('change', (e)=>{
|
el('file-input').addEventListener('change', async (e)=>{
|
||||||
for (const f of e.target.files) fileHandlerModule.addFiles([f]);
|
await fileHandlerModule.addFiles(Array.from(e.target.files || []));
|
||||||
fileHandlerModule.renderAttachStrip();
|
e.target.value = '';
|
||||||
// Refocus textarea after file picker closes (mobile keyboard)
|
// Refocus textarea after file picker closes (mobile keyboard)
|
||||||
const ta = el('message');
|
const ta = el('message');
|
||||||
if (ta) setTimeout(() => ta.focus(), 100);
|
if (ta) setTimeout(() => ta.focus(), 100);
|
||||||
@@ -146,7 +322,7 @@ function initializeEventListeners() {
|
|||||||
if (item.kind === 'file'){
|
if (item.kind === 'file'){
|
||||||
const f = item.getAsFile();
|
const f = item.getAsFile();
|
||||||
if (f) {
|
if (f) {
|
||||||
fileHandlerModule.addFiles([f]);
|
await fileHandlerModule.addFiles([f]);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,8 +482,10 @@ function initializeEventListeners() {
|
|||||||
}
|
}
|
||||||
const body = child.querySelector('.body');
|
const body = child.querySelector('.body');
|
||||||
// Prefer dataset.raw (original markdown) over innerText (rendered HTML as text)
|
// Prefer dataset.raw (original markdown) over innerText (rendered HTML as text)
|
||||||
// to avoid extra newlines and formatting artifacts.
|
// to avoid extra newlines and formatting artifacts. Raw text lives on
|
||||||
const text = body ? (body.dataset.raw || body.innerText || body.textContent || '').trim() : '';
|
// 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}`);
|
if (text) parts.push(`${label}: ${text}`);
|
||||||
} else if (child.classList?.contains('agent-thread')) {
|
} else if (child.classList?.contains('agent-thread')) {
|
||||||
const lines = ['[Tool calls]'];
|
const lines = ['[Tool calls]'];
|
||||||
@@ -1617,6 +1795,7 @@ function initializeEventListeners() {
|
|||||||
// Delay tool glow-up for a staggered effect
|
// Delay tool glow-up for a staggered effect
|
||||||
setTimeout(() => applyModeToToggles(mode), 500);
|
setTimeout(() => applyModeToToggles(mode), 500);
|
||||||
}
|
}
|
||||||
|
window.__odysseusSetChatMode = setMode;
|
||||||
agentBtn.addEventListener('click', () => {
|
agentBtn.addEventListener('click', () => {
|
||||||
// Agent mode turns off research if active
|
// Agent mode turns off research if active
|
||||||
const resChk = el('research-toggle');
|
const resChk = el('research-toggle');
|
||||||
@@ -1692,10 +1871,29 @@ function initializeEventListeners() {
|
|||||||
try { workspaceModule.initWorkspace(); } catch (_) {}
|
try { workspaceModule.initWorkspace(); } catch (_) {}
|
||||||
|
|
||||||
// Document editor toggle (special: uses module panel, not a checkbox)
|
// Document editor toggle (special: uses module panel, not a checkbox)
|
||||||
|
function bringOpenDocumentToFrontOnMobile() {
|
||||||
|
if (window.innerWidth > 768) return false;
|
||||||
|
if (!documentModule || !documentModule.isPanelOpen || !documentModule.isPanelOpen()) return false;
|
||||||
|
if (!document.body.classList.contains('email-front')) return false;
|
||||||
|
document.body.classList.remove('email-front', 'email-doc-split-active');
|
||||||
|
document.documentElement.style.removeProperty('--email-doc-split-left-x');
|
||||||
|
document.documentElement.style.removeProperty('--email-doc-split-email-w');
|
||||||
|
document.documentElement.style.removeProperty('--email-doc-split-right-x');
|
||||||
|
const docPane = document.getElementById('doc-editor-pane');
|
||||||
|
if (docPane) docPane.style.setProperty('z-index', '10010', 'important');
|
||||||
|
const overflow = el('overflow-doc-btn');
|
||||||
|
if (overflow) overflow.classList.add('active');
|
||||||
|
const indicator = el('doc-indicator-btn');
|
||||||
|
if (indicator) indicator.classList.add('active');
|
||||||
|
const st = loadToggleState(); st.doc = true; saveToggleState(st);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const overflowDocBtn = el('overflow-doc-btn');
|
const overflowDocBtn = el('overflow-doc-btn');
|
||||||
if (overflowDocBtn) {
|
if (overflowDocBtn) {
|
||||||
overflowDocBtn.addEventListener('click', async () => {
|
overflowDocBtn.addEventListener('click', async () => {
|
||||||
if (!documentModule) return;
|
if (!documentModule) return;
|
||||||
|
if (bringOpenDocumentToFrontOnMobile()) return;
|
||||||
if (documentModule.isPanelOpen()) {
|
if (documentModule.isPanelOpen()) {
|
||||||
documentModule.closePanel();
|
documentModule.closePanel();
|
||||||
overflowDocBtn.classList.remove('active');
|
overflowDocBtn.classList.remove('active');
|
||||||
@@ -2101,7 +2299,7 @@ function initializeEventListeners() {
|
|||||||
const pickerWrap = el('model-picker-wrap');
|
const pickerWrap = el('model-picker-wrap');
|
||||||
if (!inputTop || !pickerWrap) return;
|
if (!inputTop || !pickerWrap) return;
|
||||||
|
|
||||||
const PLACEHOLDER_HIDE_WIDTH = 400;
|
const PLACEHOLDER_COMPACT_WIDTH = 400;
|
||||||
const PICKER_HIDE_WIDTH = 220;
|
const PICKER_HIDE_WIDTH = 220;
|
||||||
const TOOLBAR_HIDE_WIDTH = 160;
|
const TOOLBAR_HIDE_WIDTH = 160;
|
||||||
const textarea = el('message');
|
const textarea = el('message');
|
||||||
@@ -2114,9 +2312,10 @@ function initializeEventListeners() {
|
|||||||
const w = inputTop.clientWidth;
|
const w = inputTop.clientWidth;
|
||||||
// Hide model picker
|
// Hide model picker
|
||||||
pickerWrap.classList.toggle('picker-auto-hidden', w < PICKER_HIDE_WIDTH);
|
pickerWrap.classList.toggle('picker-auto-hidden', w < PICKER_HIDE_WIDTH);
|
||||||
// Hide placeholder text
|
// Keep a prompt inside the composer even when the picker crowds the row.
|
||||||
|
// A blank placeholder makes the mobile/compact empty state feel broken.
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.setAttribute('placeholder', w < PLACEHOLDER_HIDE_WIDTH ? '' : 'Message Odysseus...');
|
textarea.setAttribute('placeholder', w < PLACEHOLDER_COMPACT_WIDTH ? 'Message...' : 'Message Odysseus...');
|
||||||
}
|
}
|
||||||
// Hide entire bottom toolbar (tools, mode toggle) — only send button remains
|
// Hide entire bottom toolbar (tools, mode toggle) — only send button remains
|
||||||
if (inputBottom) {
|
if (inputBottom) {
|
||||||
@@ -2304,14 +2503,32 @@ function initializeEventListeners() {
|
|||||||
// IMPORTANT: don't overwrite the user's persisted per-mode tool prefs
|
// IMPORTANT: don't overwrite the user's persisted per-mode tool prefs
|
||||||
// (`web_agent`, `bash_agent`, `web_chat`, `bash_chat`). Nobody mode is
|
// (`web_agent`, `bash_agent`, `web_chat`, `bash_chat`). Nobody mode is
|
||||||
// ephemeral — their agent-mode defaults must come back on toggle-off.
|
// ephemeral — their agent-mode defaults must come back on toggle-off.
|
||||||
|
const beforeNobody = Storage.getJSON(Storage.KEYS.TOGGLES, {}) || {};
|
||||||
|
if (!beforeNobody.nobody_prev_mode) beforeNobody.nobody_prev_mode = beforeNobody.mode || 'agent';
|
||||||
|
Storage.setJSON(Storage.KEYS.TOGGLES, beforeNobody);
|
||||||
const _offIds = ['web-toggle', 'bash-toggle', 'research-toggle'];
|
const _offIds = ['web-toggle', 'bash-toggle', 'research-toggle'];
|
||||||
_offIds.forEach(id => { const c = el(id); if (c) c.checked = false; });
|
_offIds.forEach(id => { const c = el(id); if (c) c.checked = false; });
|
||||||
['web-toggle-btn', 'bash-toggle-btn'].forEach(id => { const b = el(id); if (b) b.classList.remove('active'); });
|
['web-toggle-btn', 'bash-toggle-btn'].forEach(id => { const b = el(id); if (b) b.classList.remove('active'); });
|
||||||
|
if (typeof window.__odysseusSetChatMode === 'function') {
|
||||||
|
window.__odysseusSetChatMode('chat');
|
||||||
|
} else {
|
||||||
const _ab = el('mode-agent-btn'), _cb = el('mode-chat-btn');
|
const _ab = el('mode-agent-btn'), _cb = el('mode-chat-btn');
|
||||||
if (_ab) _ab.classList.remove('active');
|
if (_ab) {
|
||||||
if (_cb) _cb.classList.add('active');
|
_ab.classList.remove('active');
|
||||||
|
_ab.setAttribute('aria-pressed', 'false');
|
||||||
|
}
|
||||||
|
if (_cb) {
|
||||||
|
_cb.classList.add('active');
|
||||||
|
_cb.setAttribute('aria-pressed', 'true');
|
||||||
|
}
|
||||||
|
const _toggle = _ab?.closest('.mode-toggle') || _cb?.closest('.mode-toggle');
|
||||||
|
if (_toggle) _toggle.classList.add('mode-chat');
|
||||||
const ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
|
const ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
|
||||||
ts.research = false; ts.mode = 'chat';
|
ts.mode = 'chat';
|
||||||
|
Storage.setJSON(Storage.KEYS.TOGGLES, ts);
|
||||||
|
}
|
||||||
|
const ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
|
||||||
|
ts.research = false;
|
||||||
Storage.setJSON(Storage.KEYS.TOGGLES, ts);
|
Storage.setJSON(Storage.KEYS.TOGGLES, ts);
|
||||||
} else {
|
} else {
|
||||||
incognitoBtn.innerHTML = INCOGNITO_EYE_OPEN + '<span class="incognito-label">Nobody</span>';
|
incognitoBtn.innerHTML = INCOGNITO_EYE_OPEN + '<span class="incognito-label">Nobody</span>';
|
||||||
@@ -2335,11 +2552,15 @@ function initializeEventListeners() {
|
|||||||
// Heal any previously-persisted false values from the old Nobody bug
|
// Heal any previously-persisted false values from the old Nobody bug
|
||||||
// so agent-mode defaults (web/bash ON) come back.
|
// so agent-mode defaults (web/bash ON) come back.
|
||||||
const _ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
|
const _ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
|
||||||
let _dirty = false;
|
const _restoreMode = _ts.nobody_prev_mode || 'agent';
|
||||||
|
delete _ts.nobody_prev_mode;
|
||||||
['web_agent', 'bash_agent', 'web_chat', 'bash_chat'].forEach(k => {
|
['web_agent', 'bash_agent', 'web_chat', 'bash_chat'].forEach(k => {
|
||||||
if (_ts[k] === false) { delete _ts[k]; _dirty = true; }
|
if (_ts[k] === false) delete _ts[k];
|
||||||
});
|
});
|
||||||
if (_dirty) Storage.setJSON(Storage.KEYS.TOGGLES, _ts);
|
Storage.setJSON(Storage.KEYS.TOGGLES, _ts);
|
||||||
|
if (typeof window.__odysseusSetChatMode === 'function') {
|
||||||
|
window.__odysseusSetChatMode(_restoreMode === 'chat' ? 'chat' : 'agent');
|
||||||
|
}
|
||||||
// Reapply the current mode's real defaults to the visible toggles
|
// Reapply the current mode's real defaults to the visible toggles
|
||||||
const _curMode = (Storage.getJSON(Storage.KEYS.TOGGLES, {}) || {}).mode || 'chat';
|
const _curMode = (Storage.getJSON(Storage.KEYS.TOGGLES, {}) || {}).mode || 'chat';
|
||||||
try { applyModeToToggles(_curMode); } catch (_) {}
|
try { applyModeToToggles(_curMode); } catch (_) {}
|
||||||
@@ -3005,10 +3226,7 @@ function initializeEventListeners() {
|
|||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// New session button on icon rail
|
async function _handleNewChatAction({ preferModel = true, focus = true } = {}) {
|
||||||
const railNewSession = el('rail-new-session');
|
|
||||||
if (railNewSession) {
|
|
||||||
railNewSession.addEventListener('click', async () => {
|
|
||||||
if (!sessionModule) return;
|
if (!sessionModule) return;
|
||||||
if (_closeCompareIfActive()) return;
|
if (_closeCompareIfActive()) return;
|
||||||
_deactivateIncognito();
|
_deactivateIncognito();
|
||||||
@@ -3017,18 +3235,24 @@ function initializeEventListeners() {
|
|||||||
// Clear research mode if active
|
// Clear research mode if active
|
||||||
const _resChk = el('research-toggle');
|
const _resChk = el('research-toggle');
|
||||||
if (_resChk && _resChk.checked) _syncResearchIndicator(false);
|
if (_resChk && _resChk.checked) _syncResearchIndicator(false);
|
||||||
if (await _createDirectChatFromPreferredModel()) return;
|
if (preferModel && await _createDirectChatFromPreferredModel()) return;
|
||||||
// No models at all — show welcome screen
|
// No models at all — show welcome screen
|
||||||
sessionModule.setCurrentSessionId(null);
|
_startFreshChat();
|
||||||
if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
|
|
||||||
const docBtn3 = el('overflow-doc-btn');
|
const docBtn3 = el('overflow-doc-btn');
|
||||||
if (docBtn3) docBtn3.classList.remove('active', 'has-docs');
|
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'));
|
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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3055,31 +3279,17 @@ function initializeEventListeners() {
|
|||||||
// Logo click → new chat (same logic as rail new-session button)
|
// Logo click → new chat (same logic as rail new-session button)
|
||||||
const brandBtn = el('sidebar-brand-btn');
|
const brandBtn = el('sidebar-brand-btn');
|
||||||
if (brandBtn) {
|
if (brandBtn) {
|
||||||
brandBtn.addEventListener('click', async () => {
|
brandBtn.addEventListener('click', async (e) => {
|
||||||
if (!sessionModule) return;
|
if (e) { e.preventDefault(); e.stopPropagation(); }
|
||||||
if (_closeCompareIfActive()) return;
|
await _handleNewChatAction();
|
||||||
_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'));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarNewChatBtn = el('sidebar-new-chat-btn');
|
const sidebarNewChatBtn = el('sidebar-new-chat-btn');
|
||||||
if (sidebarNewChatBtn) {
|
if (sidebarNewChatBtn) {
|
||||||
sidebarNewChatBtn.addEventListener('click', () => {
|
sidebarNewChatBtn.addEventListener('click', async (e) => {
|
||||||
const brandBtn = el('sidebar-brand-btn');
|
if (e) { e.preventDefault(); e.stopImmediatePropagation(); }
|
||||||
if (brandBtn) brandBtn.click();
|
await _handleNewChatAction();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3118,17 +3328,38 @@ function initializeEventListeners() {
|
|||||||
// Textarea auto-resize
|
// Textarea auto-resize
|
||||||
const textarea = el('message');
|
const textarea = el('message');
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
|
_syncMobileEnterKeyHint(textarea);
|
||||||
|
window.addEventListener('odysseus:chat-busy-change', () => _syncMobileEnterKeyHint(textarea));
|
||||||
uiModule.autoResize(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);
|
uiModule.autoResize(textarea);
|
||||||
|
_syncMobileEnterKeyHint(textarea);
|
||||||
});
|
});
|
||||||
textarea.addEventListener('paste', () => {
|
textarea.addEventListener('paste', () => {
|
||||||
setTimeout(() => uiModule.autoResize(textarea), 1);
|
setTimeout(() => uiModule.autoResize(textarea), 1);
|
||||||
});
|
});
|
||||||
textarea.addEventListener('keydown', (e) => {
|
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 ghost autocomplete is active, accept the suggestion instead of submitting
|
||||||
if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) {
|
if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -3141,8 +3372,13 @@ function initializeEventListeners() {
|
|||||||
// Check if already submitting before triggering form submission
|
// Check if already submitting before triggering form submission
|
||||||
const form = el('chat-form');
|
const form = el('chat-form');
|
||||||
if (form) {
|
if (form) {
|
||||||
const submitBtn = form.querySelector('button[type="submit"]');
|
if (_isForegroundChatBusy() && textarea.value && textarea.value.trim()) {
|
||||||
if (submitBtn) submitBtn.click();
|
if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.__odysseusQueueStreamingSubmit = Date.now();
|
||||||
|
}
|
||||||
|
_submitChatFormDirect(form);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3321,7 +3557,7 @@ function initializeEventListeners() {
|
|||||||
// Now submit the form (the /new command handler will process it)
|
// Now submit the form (the /new command handler will process it)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const form = el('chat-form');
|
const form = el('chat-form');
|
||||||
if (form) form.querySelector('button[type="submit"]').click();
|
_submitChatFormDirect(form);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -3342,8 +3578,15 @@ function initializeEventListeners() {
|
|||||||
function startOdysseusApp() {
|
function startOdysseusApp() {
|
||||||
if (window.__odysseusAppStarted) return;
|
if (window.__odysseusAppStarted) return;
|
||||||
window.__odysseusAppStarted = true;
|
window.__odysseusAppStarted = true;
|
||||||
|
const _bumpChatPriority = (ms = 10000) => {
|
||||||
|
try {
|
||||||
|
window.__odysseusChatBusyUntil = Math.max(window.__odysseusChatBusyUntil || 0, Date.now() + ms);
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
_bumpChatPriority(10000);
|
||||||
// Set CSS variables
|
// Set CSS variables
|
||||||
document.documentElement.style.setProperty('--line-height', '20px');
|
document.documentElement.style.setProperty('--line-height', '20px');
|
||||||
|
initRailHoverLabels();
|
||||||
|
|
||||||
// Smooth keyboard open/close on mobile — keep chat scrolled to bottom
|
// Smooth keyboard open/close on mobile — keep chat scrolled to bottom
|
||||||
if (window.visualViewport && 'ontouchstart' in window) {
|
if (window.visualViewport && 'ontouchstart' in window) {
|
||||||
@@ -3509,9 +3752,16 @@ function startOdysseusApp() {
|
|||||||
const chatForm = document.getElementById('chat-form');
|
const chatForm = document.getElementById('chat-form');
|
||||||
const originalSubmit = chatModule.handleChatSubmit;
|
const originalSubmit = chatModule.handleChatSubmit;
|
||||||
let _submitting = false;
|
let _submitting = false;
|
||||||
|
const _messageInput = document.getElementById('message') || document.getElementById('message-input');
|
||||||
|
if (_messageInput) {
|
||||||
|
_messageInput.addEventListener('focus', () => _bumpChatPriority(15000));
|
||||||
|
_messageInput.addEventListener('input', () => _bumpChatPriority(15000));
|
||||||
|
_messageInput.addEventListener('pointerdown', () => _bumpChatPriority(15000), { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubmit(e) {
|
function handleSubmit(e) {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
|
_bumpChatPriority(30000);
|
||||||
// Debounce: prevent double-submit while a request is being initiated
|
// Debounce: prevent double-submit while a request is being initiated
|
||||||
if (_submitting) return;
|
if (_submitting) return;
|
||||||
_submitting = true;
|
_submitting = true;
|
||||||
@@ -3563,10 +3813,31 @@ function startOdysseusApp() {
|
|||||||
return fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount() > 0;
|
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() {
|
function _updateSendBtnIcon() {
|
||||||
if (!sendBtn) return;
|
if (!sendBtn) return;
|
||||||
// Don't override if streaming (stop button) or recording
|
if (sendBtn.dataset.mode === 'streaming') {
|
||||||
if (sendBtn.dataset.mode === 'streaming' || sendBtn.dataset.mode === 'recording') return;
|
_updateStreamingSubmitButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Don't override if recording
|
||||||
|
if (sendBtn.dataset.mode === 'recording') return;
|
||||||
const prevMode = sendBtn.dataset.mode || '';
|
const prevMode = sendBtn.dataset.mode || '';
|
||||||
const hasText = messageInput && messageInput.value.trim().length > 0;
|
const hasText = messageInput && messageInput.value.trim().length > 0;
|
||||||
const hasFiles = _hasAttachments();
|
const hasFiles = _hasAttachments();
|
||||||
@@ -3662,6 +3933,12 @@ function startOdysseusApp() {
|
|||||||
const hasText = messageInput && messageInput.value.trim().length > 0;
|
const hasText = messageInput && messageInput.value.trim().length > 0;
|
||||||
const hasFiles = _hasAttachments();
|
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
|
// New chat mode — empty input, no attachments, no STT
|
||||||
if (!hasText && !hasFiles && sendBtn.dataset.mode === 'newchat') {
|
if (!hasText && !hasFiles && sendBtn.dataset.mode === 'newchat') {
|
||||||
if (sessionModule) {
|
if (sessionModule) {
|
||||||
@@ -3701,9 +3978,10 @@ function startOdysseusApp() {
|
|||||||
// Enter to send (shift+enter for newline), or new chat when empty
|
// Enter to send (shift+enter for newline), or new chat when empty
|
||||||
if (messageInput) {
|
if (messageInput) {
|
||||||
messageInput.addEventListener('keydown', (e) => {
|
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();
|
e.preventDefault();
|
||||||
// Flush the debounced icon update so dataset.mode reflects the current
|
// Flush the debounced icon update so dataset.mode reflects the current
|
||||||
// text state. Without this, a fast type-and-Enter would still see the
|
// text state. Without this, a fast type-and-Enter would still see the
|
||||||
@@ -3714,7 +3992,13 @@ function startOdysseusApp() {
|
|||||||
if (railNew) railNew.click();
|
if (railNew) railNew.click();
|
||||||
return;
|
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'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3733,7 +4017,11 @@ function startOdysseusApp() {
|
|||||||
_syncModelPickerAutohide();
|
_syncModelPickerAutohide();
|
||||||
messageInput.addEventListener('input', () => {
|
messageInput.addEventListener('input', () => {
|
||||||
_syncModelPickerAutohide();
|
_syncModelPickerAutohide();
|
||||||
|
if (sendBtn && sendBtn.dataset.mode === 'streaming') {
|
||||||
|
_updateSendBtnIcon();
|
||||||
|
} else {
|
||||||
_debouncedUpdateIcon();
|
_debouncedUpdateIcon();
|
||||||
|
}
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3788,14 +4076,13 @@ function startOdysseusApp() {
|
|||||||
_showDropHighlight();
|
_showDropHighlight();
|
||||||
});
|
});
|
||||||
|
|
||||||
chatContainer.addEventListener('drop', (e) => {
|
chatContainer.addEventListener('drop', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
_hideDropHighlight();
|
_hideDropHighlight();
|
||||||
const files = Array.from(e.dataTransfer.files);
|
const files = Array.from(e.dataTransfer.files);
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
fileHandlerModule.addFiles(files);
|
await fileHandlerModule.addFiles(files);
|
||||||
fileHandlerModule.renderAttachStrip();
|
|
||||||
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
|
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3812,12 +4099,13 @@ function startOdysseusApp() {
|
|||||||
attachStrip.style.borderRadius = '4px';
|
attachStrip.style.borderRadius = '4px';
|
||||||
});
|
});
|
||||||
|
|
||||||
attachStrip.addEventListener('drop', (e) => {
|
attachStrip.addEventListener('drop', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
attachStrip.style.backgroundColor = '';
|
attachStrip.style.backgroundColor = '';
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
const files = Array.from(e.dataTransfer.files);
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
await fileHandlerModule.addFiles(files);
|
||||||
|
|
||||||
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
|
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
|
||||||
|
|
||||||
@@ -3885,14 +4173,13 @@ function startOdysseusApp() {
|
|||||||
if (_compareActive() && !e.relatedTarget) _hideCmpShield();
|
if (_compareActive() && !e.relatedTarget) _hideCmpShield();
|
||||||
}, true);
|
}, true);
|
||||||
window.addEventListener('dragend', _hideCmpShield, true);
|
window.addEventListener('dragend', _hideCmpShield, true);
|
||||||
window.addEventListener('drop', (e) => {
|
window.addEventListener('drop', async (e) => {
|
||||||
if (!_isFileDrag(e) || !_compareActive()) return;
|
if (!_isFileDrag(e) || !_compareActive()) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_hideCmpShield();
|
_hideCmpShield();
|
||||||
const files = Array.from(e.dataTransfer.files || []);
|
const files = Array.from(e.dataTransfer.files || []);
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
fileHandlerModule.addFiles(files);
|
await fileHandlerModule.addFiles(files);
|
||||||
fileHandlerModule.renderAttachStrip();
|
|
||||||
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to attach`);
|
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to attach`);
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
@@ -3927,23 +4214,40 @@ function startOdysseusApp() {
|
|||||||
console.error('Session module not loaded!');
|
console.error('Session module not loaded!');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-critical: load in parallel, resolve silently
|
const runNonCriticalStartup = (fn, delay = 4000) => {
|
||||||
modelsModule.refreshModels(true).then(() => {
|
let tries = 0;
|
||||||
const modelsBox = document.getElementById('models');
|
const run = () => {
|
||||||
const hasModels = modelsBox && modelsBox.querySelector('.models-row');
|
const busy = !!window.__odysseusChatBusy
|
||||||
if (!hasModels) {
|
|| Date.now() < (window.__odysseusChatBusyUntil || 0)
|
||||||
const tip = document.getElementById('welcome-tip');
|
|| !!document.querySelector('.send-btn[data-mode="streaming"], .send-btn.send-pending');
|
||||||
if (tip) tip.textContent = 'Add an AI endpoint from Settings in the sidebar, or paste an endpoint/API key into the chat.';
|
if (busy && tries < 12) {
|
||||||
|
tries += 1;
|
||||||
|
setTimeout(run, 2500);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
try { fn(); } catch (e) { console.warn('non-critical startup task failed:', e); }
|
||||||
modelsModule.refreshProviders();
|
};
|
||||||
ragModule.loadPersonalDocs();
|
setTimeout(() => {
|
||||||
memoryModule.loadMemories(); // Ensure memories are loaded on page load
|
if ('requestIdleCallback' in window) {
|
||||||
|
window.requestIdleCallback(run, { timeout: 5000 });
|
||||||
|
} else {
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
// Ensure the memory list is rendered after loading
|
// Non-critical startup work must not compete with first paint, chat send, or
|
||||||
setTimeout(async () => {
|
// chat switching. Panels load their own data when opened; these are only warmups.
|
||||||
await memoryModule.loadMemories();
|
_syncWelcomeModelHint().catch(() => {});
|
||||||
}, 1000);
|
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
|
// Ensure proper initial state
|
||||||
voiceRecorderModule.init();
|
voiceRecorderModule.init();
|
||||||
|
|||||||
+20
-24
@@ -219,8 +219,8 @@
|
|||||||
}, { once: true });
|
}, { once: true });
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css?v=20260630mdfontsize">
|
||||||
<link rel="modulepreload" href="/static/app.js">
|
<link rel="modulepreload" href="/static/app.js?v=20260630mdfontsize">
|
||||||
<link rel="modulepreload" href="/static/js/chat.js">
|
<link rel="modulepreload" href="/static/js/chat.js">
|
||||||
<link rel="modulepreload" href="/static/js/ui.js">
|
<link rel="modulepreload" href="/static/js/ui.js">
|
||||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||||
@@ -1356,7 +1356,7 @@
|
|||||||
<div class="settings-sidebar">
|
<div class="settings-sidebar">
|
||||||
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
|
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
|
||||||
<button class="settings-nav-item active" data-settings-tab="services">
|
<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>
|
<span>Add Models</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="settings-nav-item" data-settings-tab="added-models">
|
<button class="settings-nav-item" data-settings-tab="added-models">
|
||||||
@@ -1591,8 +1591,16 @@
|
|||||||
settings backup. Re-add this card to surface the toggle
|
settings backup. Re-add this card to surface the toggle
|
||||||
again once the core experience is faster. -->
|
again once the core experience is faster. -->
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2 6 12 13 22 6"/></svg>Email Safety<span style="flex:1"></span><label class="admin-switch" title="When on, agent send_email and reply_to_email tools stage a draft for your approval instead of sending immediately."><input type="checkbox" id="set-agentEmailConfirm" checked><span class="admin-slider"></span></label></h2>
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Writing Style</h2>
|
||||||
<div class="admin-toggle-sub" style="margin-bottom:8px">When on, agent <code>send_email</code> / <code>reply_to_email</code> tools stage a draft for your approval (in the chat) instead of SMTPing immediately. Stops models from inventing a signature and sending it to a real recipient before you can review.</div>
|
<div class="admin-toggle-sub" style="margin-bottom:8px">Used when AI drafts email replies. Keep this email-specific: greetings, sign-off, tone, and length.</div>
|
||||||
|
<div class="settings-col">
|
||||||
|
<textarea id="set-email-style" rows="6" class="settings-select" style="font-family:inherit;resize:none" placeholder="e.g. I write emails in this style. I don't use exclamation marks. I sign emails with: ..."></textarea>
|
||||||
|
<div class="settings-row" style="margin-top:4px">
|
||||||
|
<span id="set-email-style-msg" style="font-size:11px;"></span>
|
||||||
|
<button class="admin-btn-add" id="set-email-style-extract" style="margin-left:auto;display:inline-flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Extract from Sent (15 emails)</button>
|
||||||
|
<button class="admin-btn-add" id="set-email-style-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1971,18 +1979,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-card">
|
|
||||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Writing Style</h2>
|
|
||||||
<div class="admin-toggle-sub" style="margin-bottom:8px">AI-extracted from your sent emails. Used when AI drafts replies.</div>
|
|
||||||
<div class="settings-col">
|
|
||||||
<textarea id="set-email-style" rows="6" class="settings-select" style="font-family:inherit;resize:none" placeholder="e.g. I write emails in this style. I don't use exclamation marks. I sign emails with: ..."></textarea>
|
|
||||||
<div class="settings-row" style="margin-top:4px">
|
|
||||||
<span id="set-email-style-msg" style="font-size:11px;"></span>
|
|
||||||
<button class="admin-btn-add" id="set-email-style-extract" style="margin-left:auto;display:inline-flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Extract from Sent (15 emails)</button>
|
|
||||||
<button class="admin-btn-add" id="set-email-style-save">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ REMINDERS TAB ═══ -->
|
<!-- ═══ REMINDERS TAB ═══ -->
|
||||||
@@ -2098,7 +2094,7 @@
|
|||||||
|
|
||||||
<!-- ── Local card ─────────────────────────────────────────── -->
|
<!-- ── Local card ─────────────────────────────────────────── -->
|
||||||
<div class="admin-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>
|
<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;">
|
<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
|
<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
|
||||||
@@ -2130,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;">
|
<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>
|
</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;">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-model-form-row" id="adm-epLocalApiKey-row" style="display:none;">
|
<div class="admin-model-form-row" id="adm-epLocalApiKey-row" style="display:none;">
|
||||||
@@ -2143,7 +2139,7 @@
|
|||||||
|
|
||||||
<!-- ── API card ───────────────────────────────────────────── -->
|
<!-- ── API card ───────────────────────────────────────────── -->
|
||||||
<div class="admin-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>
|
<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;">
|
<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
|
<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
|
||||||
@@ -2212,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;">
|
<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>
|
</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;">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
|
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
|
||||||
@@ -2475,10 +2471,10 @@
|
|||||||
<script type="module" src="/static/js/tts-ai.js"></script>
|
<script type="module" src="/static/js/tts-ai.js"></script>
|
||||||
<script type="module" src="/static/js/document.js"></script>
|
<script type="module" src="/static/js/document.js"></script>
|
||||||
<script type="module" src="/static/js/gallery.js"></script>
|
<script type="module" src="/static/js/gallery.js"></script>
|
||||||
<script type="module" src="/static/js/chatRenderer.js"></script>
|
<script type="module" src="/static/js/chatRenderer.js?v=20260630toolmetrics"></script>
|
||||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||||
<script type="module" src="/static/js/chatStream.js"></script>
|
<script type="module" src="/static/js/chatStream.js"></script>
|
||||||
<script type="module" src="/static/js/chat.js?v=20260609ws"></script>
|
<script type="module" src="/static/js/chat.js?v=20260630toolmetrics"></script>
|
||||||
<script type="module" src="/static/js/cookbook.js"></script>
|
<script type="module" src="/static/js/cookbook.js"></script>
|
||||||
<script src="/static/js/cookbookSchedule.js"></script>
|
<script src="/static/js/cookbookSchedule.js"></script>
|
||||||
<script type="module" src="/static/js/search-chat.js"></script>
|
<script type="module" src="/static/js/search-chat.js"></script>
|
||||||
@@ -2488,7 +2484,7 @@
|
|||||||
<script type="module" src="/static/js/settings.js"></script>
|
<script type="module" src="/static/js/settings.js"></script>
|
||||||
<script type="module" src="/static/js/admin.js"></script>
|
<script type="module" src="/static/js/admin.js"></script>
|
||||||
<script type="module" src="/static/js/assistant.js"></script>
|
<script type="module" src="/static/js/assistant.js"></script>
|
||||||
<script type="module" src="/static/app.js"></script> <!-- app.js must be LAST -->
|
<script type="module" src="/static/app.js?v=20260630mdfontsize"></script> <!-- app.js must be LAST -->
|
||||||
<script type="module" src="/static/js/init.js"></script>
|
<script type="module" src="/static/js/init.js"></script>
|
||||||
<script type="module" src="/static/js/a11y.js"></script>
|
<script type="module" src="/static/js/a11y.js"></script>
|
||||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
||||||
|
|||||||
+67
-19
@@ -471,7 +471,7 @@ async function loadEndpoints() {
|
|||||||
const listLegacy = el('adm-epList');
|
const listLegacy = el('adm-epList');
|
||||||
// Refresh model picker so new endpoints show up in chat
|
// Refresh model picker so new endpoints show up in chat
|
||||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||||
window.modelsModule.refreshModels();
|
window.modelsModule.refreshModels(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
||||||
window.sessionModule.updateModelPicker();
|
window.sessionModule.updateModelPicker();
|
||||||
@@ -499,7 +499,8 @@ async function loadEndpoints() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rowHtml = data.map(ep => {
|
const rowHtml = data.map(ep => {
|
||||||
const visibleCount = ep.models.length;
|
const epModels = Array.isArray(ep.models) ? ep.models : [];
|
||||||
|
const visibleCount = epModels.length;
|
||||||
const totalCount = visibleCount + (ep.hidden_count || 0);
|
const totalCount = visibleCount + (ep.hidden_count || 0);
|
||||||
// `ep.models` is the *visible* set — when every model is hidden it's
|
// `ep.models` is the *visible* set — when every model is hidden it's
|
||||||
// empty, but we still need to render the expand panel so the user can
|
// empty, but we still need to render the expand panel so the user can
|
||||||
@@ -1393,34 +1394,77 @@ function initEndpointForm() {
|
|||||||
_refreshOfflineCount();
|
_refreshOfflineCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
const probeAllBtn = el('adm-epProbeAllBtn');
|
const _fetchWithTimeout = async (url, opts = {}, timeoutMs = 25000) => {
|
||||||
if (probeAllBtn) {
|
const ctrl = new AbortController();
|
||||||
probeAllBtn.addEventListener('click', async () => {
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...opts, signal: ctrl.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const _collectAddedEndpointIds = async () => {
|
||||||
|
const domIds = Array.from(document.querySelectorAll('[data-adm-ep-id]'))
|
||||||
|
.map(r => r.getAttribute('data-adm-ep-id'))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (domIds.length) return Array.from(new Set(domIds));
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||||
|
const data = await res.json().catch(() => []);
|
||||||
|
return (Array.isArray(data) ? data : []).map(ep => ep && ep.id).filter(Boolean);
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const _setProbeAllButtonLabel = async (btn, text, whirlpoolRef) => {
|
||||||
|
btn.innerHTML = '';
|
||||||
|
if (whirlpoolRef && whirlpoolRef.element) btn.appendChild(whirlpoolRef.element);
|
||||||
|
btn.appendChild(document.createTextNode(text));
|
||||||
|
};
|
||||||
|
if (!window.__admEpProbeAllWired) {
|
||||||
|
window.__admEpProbeAllWired = true;
|
||||||
|
document.addEventListener('click', async (ev) => {
|
||||||
|
const probeAllBtn = ev.target.closest('#adm-epProbeAllBtn');
|
||||||
|
if (!probeAllBtn || probeAllBtn.disabled) return;
|
||||||
|
ev.preventDefault();
|
||||||
probeAllBtn.disabled = true;
|
probeAllBtn.disabled = true;
|
||||||
const origHTML = probeAllBtn.innerHTML;
|
const origHTML = probeAllBtn.innerHTML;
|
||||||
let _wp = null;
|
let _wp = null;
|
||||||
|
try {
|
||||||
try {
|
try {
|
||||||
const sp = window.spinnerModule || (await import('./spinner.js')).default;
|
const sp = window.spinnerModule || (await import('./spinner.js')).default;
|
||||||
_wp = sp.createWhirlpool(11);
|
_wp = sp.createWhirlpool(11);
|
||||||
_wp.element.style.cssText = 'display:inline-flex;width:11px;height:11px;margin:0 4px 0 0;';
|
_wp.element.style.cssText = 'display:inline-flex;width:11px;height:11px;margin:0 4px 0 0;';
|
||||||
probeAllBtn.innerHTML = '';
|
await _setProbeAllButtonLabel(probeAllBtn, 'Probing', _wp);
|
||||||
probeAllBtn.appendChild(_wp.element);
|
|
||||||
probeAllBtn.appendChild(document.createTextNode('Probing'));
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
probeAllBtn.innerHTML = '<span style="opacity:0.7;">Probing…</span>';
|
probeAllBtn.innerHTML = '<span style="opacity:0.7;">Probing...</span>';
|
||||||
}
|
}
|
||||||
try {
|
await _fetchWithTimeout('/api/model-endpoints/probe-local', { credentials: 'same-origin' }, 12000).catch(() => null);
|
||||||
// Hit the bulk local probe (same one the model picker uses).
|
const ids = await _collectAddedEndpointIds();
|
||||||
await fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }).catch(() => {});
|
if (!ids.length) {
|
||||||
// Then per-endpoint /probe for the rest so API/cloud endpoints
|
await loadEndpoints();
|
||||||
// refresh too. Parallel — capped to 6 at a time so we don't
|
if (uiModule && uiModule.showToast) uiModule.showToast('No endpoints to probe', 1800);
|
||||||
// hammer the backend on a big list.
|
return;
|
||||||
const ids = Array.from(document.querySelectorAll('[data-adm-ep-id]')).map(r => r.getAttribute('data-adm-ep-id')).filter(Boolean);
|
}
|
||||||
|
let done = 0;
|
||||||
|
let failed = 0;
|
||||||
const lane = async (id) => {
|
const lane = async (id) => {
|
||||||
try { await fetch(`/api/model-endpoints/${id}/probe`, { credentials: 'same-origin' }); } catch (_) {}
|
try {
|
||||||
|
const res = await _fetchWithTimeout(`/api/model-endpoints/${encodeURIComponent(id)}/models?refresh=true&refresh_timeout=20`, {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}, 25000);
|
||||||
|
if (!res || !res.ok || res.headers.get('X-Model-Refresh-Status') === 'failed') failed += 1;
|
||||||
|
else await res.json().catch(() => null);
|
||||||
|
} catch (err) {
|
||||||
|
failed += 1;
|
||||||
|
console.warn('Endpoint probe failed', id, err);
|
||||||
|
} finally {
|
||||||
|
done += 1;
|
||||||
|
try { await _setProbeAllButtonLabel(probeAllBtn, `Probing ${done}/${ids.length}`, _wp); } catch (_) {}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const queue = [...ids];
|
const queue = [...ids];
|
||||||
const workers = Array.from({length: Math.min(6, queue.length)}, () => (async () => {
|
const workers = Array.from({ length: Math.min(4, queue.length) }, () => (async () => {
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const id = queue.shift();
|
const id = queue.shift();
|
||||||
if (id) await lane(id);
|
if (id) await lane(id);
|
||||||
@@ -1428,7 +1472,11 @@ function initEndpointForm() {
|
|||||||
})());
|
})());
|
||||||
await Promise.all(workers);
|
await Promise.all(workers);
|
||||||
await loadEndpoints();
|
await loadEndpoints();
|
||||||
if (uiModule && uiModule.showToast) uiModule.showToast('Endpoint status refreshed', 1800);
|
_refreshOfflineCount();
|
||||||
|
if (uiModule && uiModule.showToast) {
|
||||||
|
const ok = Math.max(0, ids.length - failed);
|
||||||
|
uiModule.showToast(failed ? `Probed ${ok}/${ids.length} endpoints; ${failed} failed` : `Probed ${ids.length} endpoints`, failed ? 4200 : 1800);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (_wp) { try { _wp.destroy(); } catch (_) {} }
|
if (_wp) { try { _wp.destroy(); } catch (_) {} }
|
||||||
probeAllBtn.innerHTML = origHTML;
|
probeAllBtn.innerHTML = origHTML;
|
||||||
|
|||||||
+102
-21
@@ -301,7 +301,7 @@ async function _updateEvent(uid, data) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _deleteEvent(uid) {
|
async function _deleteEvent(uid, { scope = 'series' } = {}) {
|
||||||
// Multiple "sibling" UIDs may need to vanish optimistically:
|
// Multiple "sibling" UIDs may need to vanish optimistically:
|
||||||
// 1. The exact uid the user clicked.
|
// 1. The exact uid the user clicked.
|
||||||
// 2. If the user clicked a RECURRING occurrence (uid contains "::"),
|
// 2. If the user clicked a RECURRING occurrence (uid contains "::"),
|
||||||
@@ -312,9 +312,12 @@ async function _deleteEvent(uid) {
|
|||||||
// other days kept rendering until the next full refresh.
|
// other days kept rendering until the next full refresh.
|
||||||
// 3. If the user clicked the master, strip every "master::*"
|
// 3. If the user clicked the master, strip every "master::*"
|
||||||
// expansion (same prefix scan).
|
// expansion (same prefix scan).
|
||||||
|
const deleteOccurrenceOnly = scope === 'occurrence' && uid.includes('::');
|
||||||
const masterUid = uid.includes('::') ? uid.split('::')[0] : uid;
|
const masterUid = uid.includes('::') ? uid.split('::')[0] : uid;
|
||||||
const backups = {};
|
const backups = {};
|
||||||
const _matches = (k) => k === uid || k === masterUid || k.startsWith(masterUid + '::');
|
const _matches = deleteOccurrenceOnly
|
||||||
|
? (k) => k === uid
|
||||||
|
: (k) => k === uid || k === masterUid || k.startsWith(masterUid + '::');
|
||||||
|
|
||||||
for (const k of Object.keys(_allEvents)) {
|
for (const k of Object.keys(_allEvents)) {
|
||||||
if (_matches(k)) {
|
if (_matches(k)) {
|
||||||
@@ -328,7 +331,8 @@ async function _deleteEvent(uid) {
|
|||||||
if (_open) _render();
|
if (_open) _render();
|
||||||
_updateBadge && _updateBadge();
|
_updateBadge && _updateBadge();
|
||||||
const isRecurring = uid.includes('::');
|
const isRecurring = uid.includes('::');
|
||||||
fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, {
|
const scopeParam = deleteOccurrenceOnly ? '?scope=occurrence' : '';
|
||||||
|
fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}${scopeParam}`, {
|
||||||
method: 'DELETE', credentials: 'same-origin',
|
method: 'DELETE', credentials: 'same-origin',
|
||||||
}).then(r => {
|
}).then(r => {
|
||||||
// 404 = the event was already deleted by another session/device. That's
|
// 404 = the event was already deleted by another session/device. That's
|
||||||
@@ -430,14 +434,77 @@ function _todayCount() {
|
|||||||
}).length;
|
}).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-event ⋮ menu: Remind me / Delete
|
function _findEventByUid(uid) {
|
||||||
|
return _allEvents[uid] || _events.find(e => e && e.uid === uid) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isRecurringEvent(ev) {
|
||||||
|
return !!(ev && (ev.is_recurrence || ev.uid?.includes('::') || ev.rrule));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _chooseRecurringDeleteScope(ev) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const name = ev?.summary ? `"${ev.summary}"` : 'this event';
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal';
|
||||||
|
overlay.style.display = '';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal-content styled-confirm-box" role="dialog" aria-modal="true" aria-labelledby="cal-delete-choice-title">
|
||||||
|
<div class="modal-header"><h4 id="cal-delete-choice-title">Delete recurring event</h4></div>
|
||||||
|
<div class="modal-body"><p>Delete ${_e(name)}?</p></div>
|
||||||
|
<div class="modal-footer" style="gap:8px;flex-wrap:wrap;">
|
||||||
|
<button class="confirm-btn confirm-btn-secondary" data-choice="cancel">Cancel</button>
|
||||||
|
<button class="confirm-btn confirm-btn-danger" data-choice="occurrence">This event only</button>
|
||||||
|
<button class="confirm-btn confirm-btn-danger" data-choice="series">All recurring events</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
const close = (value) => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
overlay.remove();
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
close(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) return close(null);
|
||||||
|
const btn = e.target.closest('[data-choice]');
|
||||||
|
if (!btn) return;
|
||||||
|
const choice = btn.dataset.choice;
|
||||||
|
close(choice === 'cancel' ? null : choice);
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
overlay.querySelector('[data-choice="occurrence"]')?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _confirmAndDeleteEvent(ev) {
|
||||||
|
if (!ev) return;
|
||||||
|
const name = ev.summary ? `"${ev.summary}"` : 'this event';
|
||||||
|
let scope = 'series';
|
||||||
|
if (_isRecurringEvent(ev)) {
|
||||||
|
scope = await _chooseRecurringDeleteScope(ev);
|
||||||
|
if (!scope) return;
|
||||||
|
} else {
|
||||||
|
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
try { await _deleteEvent(ev.uid, { scope }); setTimeout(() => _render(), 100); }
|
||||||
|
catch (_) { uiModule.showToast('Failed to delete'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-event ⋮ menu: Edit / Delete
|
||||||
function _wireQuickDelete(body) {
|
function _wireQuickDelete(body) {
|
||||||
body.querySelectorAll('.cal-event-more').forEach(btn => {
|
body.querySelectorAll('.cal-event-more').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const uid = btn.dataset.uid;
|
const uid = btn.dataset.uid;
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
const ev = _allEvents[uid];
|
const ev = _findEventByUid(uid);
|
||||||
if (!ev) return;
|
if (!ev) return;
|
||||||
_showEventMoreMenu(ev, btn);
|
_showEventMoreMenu(ev, btn);
|
||||||
});
|
});
|
||||||
@@ -490,10 +557,7 @@ function _showEventMoreMenu(ev, anchor) {
|
|||||||
|
|
||||||
dropdown.appendChild(_item(_trashIcon, 'Delete', async () => {
|
dropdown.appendChild(_item(_trashIcon, 'Delete', async () => {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
const name = ev.summary ? `"${ev.summary}"` : 'this event';
|
await _confirmAndDeleteEvent(ev);
|
||||||
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
|
|
||||||
if (!ok) return;
|
|
||||||
try { await _deleteEvent(ev.uid); setTimeout(() => _render(), 100); } catch (_) {}
|
|
||||||
}, true));
|
}, true));
|
||||||
|
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
@@ -1814,7 +1878,7 @@ function _dayDetailHTML(dateStr) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
if (_searchQuery) {
|
if (_searchQuery) {
|
||||||
const q = _searchQuery.toLowerCase();
|
const q = _searchQuery.toLowerCase();
|
||||||
const results = _events
|
const results = Object.values(_allEvents || {})
|
||||||
.filter(_eventVisible)
|
.filter(_eventVisible)
|
||||||
.filter(e =>
|
.filter(e =>
|
||||||
(e.summary || '').toLowerCase().includes(q) ||
|
(e.summary || '').toLowerCase().includes(q) ||
|
||||||
@@ -3116,7 +3180,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
all_day: isAD,
|
all_day: isAD,
|
||||||
description: document.getElementById('cal-f-desc').value,
|
description: document.getElementById('cal-f-desc').value,
|
||||||
location: document.getElementById('cal-f-loc').value,
|
location: document.getElementById('cal-f-loc').value,
|
||||||
rrule: document.getElementById('cal-f-rrule').value || undefined,
|
rrule: document.getElementById('cal-f-rrule').value || '',
|
||||||
calendar_href: document.getElementById('cal-f-cal')?.value || (_calendars[0]?.href || ''),
|
calendar_href: document.getElementById('cal-f-cal')?.value || (_calendars[0]?.href || ''),
|
||||||
color: colorVal || undefined,
|
color: colorVal || undefined,
|
||||||
};
|
};
|
||||||
@@ -3142,11 +3206,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
} catch (e) { uiModule.showToast('Failed to save'); }
|
} catch (e) { uiModule.showToast('Failed to save'); }
|
||||||
});
|
});
|
||||||
document.getElementById('cal-f-del')?.addEventListener('click', async () => {
|
document.getElementById('cal-f-del')?.addEventListener('click', async () => {
|
||||||
const name = existing && existing.summary ? `"${existing.summary}"` : 'this event';
|
await _confirmAndDeleteEvent(existing);
|
||||||
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
|
|
||||||
if (!ok) return;
|
|
||||||
try { await _deleteEvent(existing.uid); _render(); }
|
|
||||||
catch (e) { uiModule.showToast('Failed to delete'); }
|
|
||||||
});
|
});
|
||||||
// ── Bespoke-form behavior ──────────────────────────────────────────
|
// ── Bespoke-form behavior ──────────────────────────────────────────
|
||||||
const formEl = body.querySelector('.cal-form');
|
const formEl = body.querySelector('.cal-form');
|
||||||
@@ -3455,8 +3515,18 @@ function openCalendar() {
|
|||||||
// Layer Esc: close the topmost calendar surface first, only fall through
|
// Layer Esc: close the topmost calendar surface first, only fall through
|
||||||
// to closing the whole calendar when nothing else is on top.
|
// to closing the whole calendar when nothing else is on top.
|
||||||
const settings = document.getElementById('cal-settings-panel');
|
const settings = document.getElementById('cal-settings-panel');
|
||||||
if (settings) { settings.remove(); return; }
|
if (settings) {
|
||||||
if (document.querySelector('.cal-form')) { _render(); return; }
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
settings.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (document.querySelector('.cal-form')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
_render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
closeCalendar();
|
closeCalendar();
|
||||||
}
|
}
|
||||||
else if (e.key === 'ArrowLeft') document.getElementById('cal-prev')?.click();
|
else if (e.key === 'ArrowLeft') document.getElementById('cal-prev')?.click();
|
||||||
@@ -3485,14 +3555,25 @@ async function openCalendarTo(target) {
|
|||||||
if (!target) return;
|
if (!target) return;
|
||||||
try {
|
try {
|
||||||
await _fetchCalendars();
|
await _fetchCalendars();
|
||||||
|
const targetStr = String(target || '').trim();
|
||||||
|
if (targetStr.startsWith('search:')) {
|
||||||
|
_searchQuery = targetStr.slice('search:'.length).trim();
|
||||||
|
const now = new Date();
|
||||||
|
await _fetchEvents(`${now.getFullYear()}-01-01`, `${now.getFullYear() + 2}-01-01`);
|
||||||
|
_currentDate = now;
|
||||||
|
_selectedDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
_view = 'month';
|
||||||
|
_render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// If target looks like an ISO date (YYYY-MM-DD...), go straight there.
|
// If target looks like an ISO date (YYYY-MM-DD...), go straight there.
|
||||||
let dt = null;
|
let dt = null;
|
||||||
const isoMatch = /^\d{4}-\d{2}-\d{2}/.test(String(target));
|
const isoMatch = /^\d{4}-\d{2}-\d{2}/.test(targetStr);
|
||||||
if (isoMatch) {
|
if (isoMatch) {
|
||||||
dt = new Date(target);
|
dt = new Date(targetStr);
|
||||||
} else {
|
} else {
|
||||||
// Treat as an event uid — find it among loaded events.
|
// Treat as an event uid — find it among loaded events.
|
||||||
const ev = (_events || []).find(e => e.uid === target || (e.uid || '').startsWith(target));
|
const ev = Object.values(_allEvents || {}).find(e => e.uid === targetStr || (e.uid || '').startsWith(targetStr));
|
||||||
if (ev && ev.dtstart) dt = new Date(ev.dtstart);
|
if (ev && ev.dtstart) dt = new Date(ev.dtstart);
|
||||||
if (ev) _highlightEventUid = ev.uid;
|
if (ev) _highlightEventUid = ev.uid;
|
||||||
}
|
}
|
||||||
|
|||||||
+584
-42
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user