mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-16 13:08:02 +00:00
Compare commits
355 Commits
a226c94df7
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 28d27ee723 | |||
| c80462e462 | |||
| c1d6287e32 | |||
| 5971b091db | |||
| df2fad2881 | |||
| f13e54de6e | |||
| d16b849c3e | |||
| 6b8f84553c | |||
| b571c7ddc1 | |||
| 7f3fd77121 | |||
| 732b20776c | |||
| 801c3a2ff1 | |||
| 2531ba401c | |||
| 890d6a0220 | |||
| 1f8687abeb | |||
| e6d9d68729 | |||
| 6efc07c5fc | |||
| bc771fbc1e | |||
| 21c7bf802e | |||
| e0fd68160f | |||
| def3483032 | |||
| 667f9e4cae | |||
| 7b25b6dbdc | |||
| 7a1c7395c0 | |||
| a8d215a390 | |||
| 4901a96591 | |||
| 807e92c3bc | |||
| d88c8cbacf | |||
| a35384e68f | |||
| 3064819e3d | |||
| 54f1d015b5 | |||
| 2d8177035b | |||
| c67deaa60a | |||
| 168f593096 | |||
| 038bdd85ec | |||
| b5ec40d505 | |||
| 017903de61 | |||
| 35f867c959 | |||
| 2826dcfc33 | |||
| 3592285db7 | |||
| c8169ad7a9 | |||
| 5acd0ceae9 | |||
| 9dc0d661cf | |||
| a05221571c | |||
| 264da65186 | |||
| a50e30c28b | |||
| d8d98caa78 | |||
| 440d99d02c | |||
| 7d481b250c | |||
| e3750fcdcb | |||
| 439285e1b7 | |||
| 897e6950af | |||
| 6114ef0d6d | |||
| 3dd031c139 | |||
| d3faa00aaa | |||
| a3bbe37923 | |||
| 5c16d39e91 | |||
| 6f6cb6ea88 | |||
| 43ead1a0eb | |||
| d3ab478ef1 | |||
| 1f6dc80525 | |||
| 0b3338c69d | |||
| b7df800e94 | |||
| 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 | |||
| df9c20e6c2 | |||
| bbbe145247 | |||
| 387f95187e | |||
| 00dfd2d47a | |||
| d2a6d73aa5 | |||
| 139d76ab57 | |||
| 46b127b1f3 | |||
| 19e2326a6f | |||
| ff6fd3eaa7 | |||
| 840e59cd05 | |||
| 3021569081 | |||
| a326a6a555 | |||
| dff79319d7 | |||
| 9731048ecd | |||
| 783ea99bd0 | |||
| 89119a8cea | |||
| 92cfb2a7cf | |||
| 2a28bb2729 | |||
| a6b6a22de7 | |||
| b712a0a9cb | |||
| 5d5500fbb3 | |||
| ff6359ae81 | |||
| 4a7c03d536 | |||
| e2c8b8eb37 | |||
| b419caf9f7 | |||
| 402a2771b3 | |||
| 3b6d771be9 | |||
| 4d90eb3d44 | |||
| 240768a7a1 | |||
| 1e78ac999d | |||
| 4143bfaa2a | |||
| c0a68acfc8 | |||
| 893e490cdc | |||
| bad9ec2f9c | |||
| bfeea7f463 | |||
| 927b1f7ecf | |||
| 6b617f9cad | |||
| 7094c8e285 | |||
| bb2148db73 | |||
| e018c7cf6c | |||
| 613a4c059a | |||
| 2cf7f24afc | |||
| 7fa3414308 | |||
| 55661b8925 | |||
| a7fc1343a3 | |||
| 827a6b2778 | |||
| 8066a8e0cd | |||
| 31dbc6bec6 | |||
| e58d9702b7 | |||
| cbed87e5cb | |||
| ac7cf67ab6 | |||
| 3aa48e9025 | |||
| a6903931f7 | |||
| 5b8bfdabab | |||
| ff0f1b3450 | |||
| 9782e5bc94 | |||
| a32c3f26ab | |||
| 63c07c2188 | |||
| 36ba856607 | |||
| 6bba539d65 | |||
| c01c09559a | |||
| c95bbd6992 | |||
| ded57e5740 | |||
| 0d84441c1c | |||
| 496544b487 | |||
| de4c338423 | |||
| 8b110c28e6 | |||
| 36898fba0d | |||
| 2ef44e4bd9 | |||
| c114e7652a | |||
| 259662e914 | |||
| 5f7de831f9 | |||
| fbe3a0d73b | |||
| 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 | |||
| df9907c09f | |||
| ca7be0d441 | |||
| fc6a6dc584 | |||
| cc784db006 | |||
| 002eba779e | |||
| bab45142a5 | |||
| 26adeb4d38 | |||
| e0016ade5a | |||
| 079bac1634 | |||
| a256747be9 | |||
| 70e106cdc2 | |||
| 28974ae787 | |||
| 7af3f15288 | |||
| da7c6a667b | |||
| a40371d532 | |||
| 3b4187e25d | |||
| be4c4f2926 | |||
| 8180e9cdb1 | |||
| 20cf323ca4 | |||
| 23b844f113 | |||
| 2497160fd4 | |||
| 70d806019b | |||
| 3e7af8634f | |||
| 7e9bfb1700 | |||
| e7c61a75b6 | |||
| 20691d6019 | |||
| 228efbc70a | |||
| c098355778 | |||
| 090f4078d8 | |||
| ad745801c6 | |||
| d5286f926e | |||
| 67040a196f | |||
| 497c391f84 | |||
| 95b3c8139d | |||
| a05666a1b0 | |||
| 6d429a49b9 | |||
| 2dfc83ee22 | |||
| a6400c10af | |||
| 4222039b67 | |||
| 16ddfbf966 | |||
| edd5ea36ad | |||
| e3ecdd3207 | |||
| 45ee5a71f4 | |||
| 8888819d74 | |||
| ebead8083e | |||
| a9b208f470 | |||
| d4cd6d60f1 | |||
| ac05dff73c | |||
| fcbddf3845 | |||
| ab01e7a000 | |||
| 626414584b | |||
| d5a45c1ce3 | |||
| 62a23ca4aa | |||
| fc1351d0f8 | |||
| 6cd489f79d | |||
| 6ee51b6b10 | |||
| a5b60a34ee | |||
| f5200ec45b | |||
| de12d4734a | |||
| 5d23495eb2 | |||
| 22379fe736 | |||
| 4e46e415ea | |||
| 6a2a39f892 | |||
| 87e46e576a | |||
| 413e628a30 | |||
| 5ce2056521 | |||
| e0ccf250a4 | |||
| 72c0bde8a9 | |||
| 2e16394b41 | |||
| 060dbf0681 | |||
| d9ad418195 | |||
| 08994a0a96 | |||
| e9136f801a | |||
| e90dbc1012 | |||
| d47715036a | |||
| 87407b3a09 | |||
| 119228a6db | |||
| 8f5e36a079 | |||
| 30dd789351 | |||
| e8175c9535 | |||
| bd9149f79a | |||
| fef08ed114 | |||
| 7e5db9a3c6 | |||
| 2f246c7779 | |||
| 8ec27fd903 | |||
| b57989f08c | |||
| 91bba117c1 | |||
| 4c82e4a172 | |||
| b899095f18 | |||
| 888e25624f | |||
| c062c27648 | |||
| 93ec7cbb52 | |||
| c12b8ab6c9 | |||
| e812a29233 | |||
| ca4973c41f | |||
| 91b4171b3f | |||
| d36879bd50 | |||
| b51656770d | |||
| dd055ee6e3 | |||
| 5f63a3d3bd | |||
| 993d504de3 | |||
| fbdec22dcb | |||
| 19dd82b8f6 | |||
| 57e7229219 | |||
| 92daf4e560 | |||
| 75f04bc088 | |||
| c504214925 | |||
| 160267417e | |||
| ed18192a8e | |||
| 076e8c93c9 | |||
| 8c46172e87 | |||
| e442cc859d | |||
| a01c3da75f | |||
| 23ed92d965 | |||
| 8cc76b53a2 | |||
| 20c6ba8f32 | |||
| 9adb940ef9 | |||
| 2fbfd22946 | |||
| a10bfc466b | |||
| 18f29bdfd8 | |||
| 63d9b12b22 | |||
| ee6fd8ffe8 | |||
| f01465e87f | |||
| 1324e1b0d5 | |||
| b3e186746a | |||
| d70c00e8d2 | |||
| b3ed60e95a | |||
| 37da04e8b5 | |||
| d9ebdd6fbb | |||
| f3e4e071c7 | |||
| ff7cf8e279 | |||
| 8fa10f9866 | |||
| 04ff417a10 | |||
| e8106f7c7c |
@@ -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
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ Bundled in `static/fonts/`:
|
|||||||
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
|
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
|
||||||
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
|
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
|
||||||
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
|
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
|
||||||
|
| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez |
|
||||||
|
|
||||||
## Python dependencies
|
## Python dependencies
|
||||||
|
|
||||||
|
|||||||
+61
@@ -1,3 +1,14 @@
|
|||||||
|
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
||||||
|
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
||||||
|
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
|
||||||
|
# the final image / Cookbook never has to compile the broken sdists. See
|
||||||
|
# docker/build-realesrgan-wheels.sh for the full rationale.
|
||||||
|
FROM python:3.14-slim AS realesrgan-wheels
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
||||||
|
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
||||||
|
|
||||||
FROM python:3.14-slim
|
FROM python:3.14-slim
|
||||||
|
|
||||||
# System deps. tmux is required by Cookbook for background downloads/serves.
|
# System deps. tmux is required by Cookbook for background downloads/serves.
|
||||||
@@ -18,8 +29,44 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
tmux \
|
tmux \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
gosu \
|
gosu \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0t64 \
|
||||||
|
libxcb1 \
|
||||||
|
libmagic1 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1,
|
||||||
|
# libglib-2.0/libgthread, libxcb.so.1) that opencv-python (cv2) loads. The
|
||||||
|
# slim base omits them, so the Cookbook "install realesrgan" path imports cv2
|
||||||
|
# and dies with `libxcb.so.1: cannot open shared object file` despite a clean
|
||||||
|
# pip install. Using full opencv-python (not -headless) because basicsr/gfpgan/
|
||||||
|
# facexlib/realesrgan all depend on the `opencv-python` distribution by name.
|
||||||
|
#
|
||||||
|
# libmagic1 is the shared lib (libmagic.so.1) that python-magic dlopens for
|
||||||
|
# content-based MIME sniffing in src/upload_handler.py. We install both here
|
||||||
|
# (libmagic1 + the python-magic wrapper, below) rather than in requirements.txt
|
||||||
|
# because python-magic resolves libmagic at import time: where the lib is
|
||||||
|
# absent the import can block or raise, so keeping it image-only avoids
|
||||||
|
# regressing pip/venv installs on hosts without libmagic. Debian always has the
|
||||||
|
# lib here, so the import is instant and detection actually works.
|
||||||
|
|
||||||
|
# Docker CLI (client only — daemon stays on the host via the
|
||||||
|
# /var/run/docker.sock mount). The Debian `docker.io` package ships
|
||||||
|
# dockerd but not the client binary on slim, so grab the static client
|
||||||
|
# tarball from download.docker.com instead.
|
||||||
|
ARG DOCKER_CLI_VERSION=27.5.1
|
||||||
|
RUN ARCH="$(dpkg --print-architecture)" \
|
||||||
|
&& case "$ARCH" in \
|
||||||
|
amd64) DARCH=x86_64 ;; \
|
||||||
|
arm64) DARCH=aarch64 ;; \
|
||||||
|
*) echo "unsupported arch $ARCH"; exit 1 ;; \
|
||||||
|
esac \
|
||||||
|
&& curl -fsSL "https://download.docker.com/linux/static/stable/${DARCH}/docker-${DOCKER_CLI_VERSION}.tgz" \
|
||||||
|
-o /tmp/docker.tgz \
|
||||||
|
&& tar -xzf /tmp/docker.tgz -C /tmp \
|
||||||
|
&& install -m 0755 /tmp/docker/docker /usr/local/bin/docker \
|
||||||
|
&& rm -rf /tmp/docker /tmp/docker.tgz
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python deps first (layer cache). Optional extras (PyMuPDF AGPL, etc.)
|
# Install Python deps first (layer cache). Optional extras (PyMuPDF AGPL, etc.)
|
||||||
@@ -29,6 +76,20 @@ COPY requirements.txt requirements-optional.txt ./
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt \
|
RUN pip install --no-cache-dir -r requirements.txt \
|
||||||
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
|
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
|
||||||
|
|
||||||
|
# python-magic powers content-based MIME sniffing in src/upload_handler.py.
|
||||||
|
# Image-only (not in requirements.txt) because it needs the libmagic1 system
|
||||||
|
# lib installed above; see the apt note near the top of this stage.
|
||||||
|
RUN pip install --no-cache-dir python-magic==0.4.27
|
||||||
|
|
||||||
|
# Pre-install the patched basicsr/gfpgan/facexlib wheels built in the
|
||||||
|
# realesrgan-wheels stage (--no-deps keeps the image lean — torch & friends are
|
||||||
|
# pulled only when realesrgan is actually installed). With these dists already
|
||||||
|
# satisfied, the Cookbook's plain `pip install realesrgan` resolves them from
|
||||||
|
# wheels instead of rebuilding the sdists that fail on Python 3.14.
|
||||||
|
COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/
|
||||||
|
RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
|
||||||
|
&& rm -rf /tmp/odysseus-wheels
|
||||||
|
|
||||||
# Copy app code
|
# Copy app code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/odysseus-wordmark.png" alt="Odysseus" width="280">
|
<img src="docs/odysseus-wordmark.png" alt="Odysseus" width="238">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/odysseus.jpg" alt="Odysseus interface">
|
<img src="docs/odysseus-browser.jpg" alt="Odysseus interface">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ the codebase, you are probably right to stay away.
|
|||||||
and WSL all need coverage.
|
and WSL all need coverage.
|
||||||
|
|
||||||
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
|
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
|
||||||
- Self-host troubleshooting cookbook. Document the weird 30-second fixes that otherwise become 30-minute searches: Dovecot cleartext auth for local stacks, ntfy Android Instant Delivery for non-ntfy.sh servers, clipboard limits on plain-HTTP Tailscale URLs, Radicale collection URLs, and similar traps.
|
|
||||||
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
|
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
|
||||||
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
|
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
|
||||||
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
|
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop.
|
||||||
|
# When started via `python -m uvicorn` from a terminal, uvicorn sets this
|
||||||
|
# automatically. But the VS Code debugger (and other non-uvicorn entrypoints)
|
||||||
|
# use the default SelectorEventLoop, which raises NotImplementedError on any
|
||||||
|
# subprocess call. Force ProactorEventLoop here so the right loop is always
|
||||||
|
# used, regardless of how the process is launched.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||||
|
|
||||||
|
|
||||||
def register_static_mime_types() -> None:
|
def register_static_mime_types() -> None:
|
||||||
@@ -44,7 +55,7 @@ from typing import Dict
|
|||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
|
from fastapi.responses import JSONResponse, FileResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
@@ -65,7 +76,7 @@ from core.exceptions import (
|
|||||||
|
|
||||||
import bcrypt as _bcrypt
|
import bcrypt as _bcrypt
|
||||||
|
|
||||||
from src.app_helpers import abs_join
|
from src.app_helpers import abs_join, serve_html_with_nonce
|
||||||
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
@@ -187,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
|
||||||
@@ -573,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)
|
||||||
@@ -587,14 +655,19 @@ app.include_router(setup_emoji_routes())
|
|||||||
# Sessions
|
# Sessions
|
||||||
from routes.session_routes import setup_session_routes
|
from routes.session_routes import setup_session_routes
|
||||||
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
|
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
|
||||||
app.include_router(setup_session_routes(session_manager, session_config, webhook_manager=webhook_manager))
|
app.include_router(setup_session_routes(
|
||||||
|
session_manager,
|
||||||
|
session_config,
|
||||||
|
webhook_manager=webhook_manager,
|
||||||
|
upload_handler=upload_handler,
|
||||||
|
))
|
||||||
|
|
||||||
# Admin Danger Zone wipes (Settings → System → Danger Zone)
|
# Admin Danger Zone wipes (Settings → System → Danger Zone)
|
||||||
from routes.admin_wipe_routes import setup_admin_wipe_routes
|
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
|
||||||
@@ -611,12 +684,12 @@ app.include_router(setup_chat_routes(
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Research (background deep-research tasks)
|
# Research (background deep-research tasks)
|
||||||
from routes.research_routes import setup_research_routes
|
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, upload_handler=upload_handler))
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
from routes.search_routes import setup_search_routes
|
from routes.search_routes import setup_search_routes
|
||||||
@@ -675,7 +748,7 @@ from routes.signature_routes import setup_signature_routes
|
|||||||
app.include_router(setup_signature_routes())
|
app.include_router(setup_signature_routes())
|
||||||
|
|
||||||
# Gallery (image library)
|
# Gallery (image library)
|
||||||
from routes.gallery_routes import setup_gallery_routes
|
from routes.gallery.gallery_routes import setup_gallery_routes
|
||||||
app.include_router(setup_gallery_routes())
|
app.include_router(setup_gallery_routes())
|
||||||
|
|
||||||
# Persisted image-editor drafts (server-backed projects)
|
# Persisted image-editor drafts (server-backed projects)
|
||||||
@@ -695,7 +768,7 @@ app.include_router(setup_assistant_routes(task_scheduler))
|
|||||||
|
|
||||||
# Calendar (CalDAV)
|
# Calendar (CalDAV)
|
||||||
from routes.calendar_routes import setup_calendar_routes
|
from routes.calendar_routes import setup_calendar_routes
|
||||||
calendar_router = setup_calendar_routes()
|
calendar_router = setup_calendar_routes(upload_handler=upload_handler)
|
||||||
app.include_router(calendar_router)
|
app.include_router(calendar_router)
|
||||||
|
|
||||||
# Shell (user-facing command execution)
|
# Shell (user-facing command execution)
|
||||||
@@ -758,7 +831,7 @@ logger.info("Webhook & API token routes initialized")
|
|||||||
|
|
||||||
# Notes (Google Keep-style notes/todos)
|
# Notes (Google Keep-style notes/todos)
|
||||||
from routes.note_routes import setup_note_routes
|
from routes.note_routes import setup_note_routes
|
||||||
app.include_router(setup_note_routes(task_scheduler))
|
app.include_router(setup_note_routes(task_scheduler, upload_handler=upload_handler))
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
from routes.email_routes import setup_email_routes
|
from routes.email_routes import setup_email_routes
|
||||||
@@ -783,7 +856,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
|
||||||
@@ -791,23 +864,17 @@ app.include_router(setup_companion_routes())
|
|||||||
|
|
||||||
# ========= ROUTES (kept in app.py) =========
|
# ========= ROUTES (kept in app.py) =========
|
||||||
|
|
||||||
def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
|
|
||||||
"""Read an HTML file and inject the CSP nonce into inline <script> tags."""
|
|
||||||
with open(file_path, "r", encoding="utf-8") as f:
|
|
||||||
html = f.read()
|
|
||||||
nonce = getattr(request.state, "csp_nonce", "")
|
|
||||||
html = html.replace("{{CSP_NONCE}}", nonce)
|
|
||||||
return HTMLResponse(html)
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def serve_index(request: Request):
|
async def serve_index(request: Request):
|
||||||
static_path = abs_join(BASE_DIR, "static/index.html")
|
static_path = abs_join(BASE_DIR, "static/index.html")
|
||||||
if os.path.exists(static_path):
|
if os.path.exists(static_path):
|
||||||
return _serve_html_with_nonce(request, static_path)
|
return serve_html_with_nonce(request, static_path)
|
||||||
root_path = abs_join(BASE_DIR, "index.html")
|
# No static bundle — fall back to a root-level index.html if one is shipped.
|
||||||
if os.path.exists(root_path):
|
# If neither exists, serve_html_with_nonce logs it and returns a generic 500:
|
||||||
return _serve_html_with_nonce(request, root_path)
|
# a missing index.html is a broken deployment (server fault), not a client
|
||||||
raise HTTPException(404, "index.html not found")
|
# "not found". This keeps the app-shell route consistent with the other
|
||||||
|
# bundled-template routes instead of mislabelling the fault as a 404.
|
||||||
|
return serve_html_with_nonce(request, abs_join(BASE_DIR, "index.html"))
|
||||||
|
|
||||||
@app.get("/notes")
|
@app.get("/notes")
|
||||||
async def serve_notes(request: Request):
|
async def serve_notes(request: Request):
|
||||||
@@ -848,13 +915,13 @@ async def serve_library(request: Request):
|
|||||||
@app.get("/backgrounds")
|
@app.get("/backgrounds")
|
||||||
async def serve_backgrounds(request: Request):
|
async def serve_backgrounds(request: Request):
|
||||||
"""Sandbox page for prototyping background effects. No auth required."""
|
"""Sandbox page for prototyping background effects. No auth required."""
|
||||||
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
|
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
|
||||||
|
|
||||||
@app.get("/login")
|
@app.get("/login")
|
||||||
async def serve_login(request: Request):
|
async def serve_login(request: Request):
|
||||||
if not AUTH_ENABLED:
|
if not AUTH_ENABLED:
|
||||||
return RedirectResponse(url="/", status_code=302)
|
return RedirectResponse(url="/", status_code=302)
|
||||||
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
|
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
|
||||||
|
|
||||||
@app.get("/api/version")
|
@app.get("/api/version")
|
||||||
async def get_version():
|
async def get_version():
|
||||||
@@ -865,6 +932,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.
|
||||||
@@ -953,7 +1048,7 @@ async def _startup_event():
|
|||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.warning(f"Built-in MCP registration failed (non-critical): {type(e).__name__}: {e}")
|
logger.warning(f"Built-in MCP registration failed (non-critical): {type(e).__name__}: {e}")
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(mcp_manager.connect_all_enabled(), timeout=20)
|
await mcp_manager.connect_all_enabled()
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning("User MCP startup timed out (non-critical)")
|
logger.warning("User MCP startup timed out (non-critical)")
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
@@ -961,11 +1056,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
|
||||||
@@ -977,14 +1072,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 []
|
||||||
@@ -1000,8 +1091,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:
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) ->
|
|||||||
|
|
||||||
|
|
||||||
def atomic_write_text(path: str, text: str) -> None:
|
def atomic_write_text(path: str, text: str) -> None:
|
||||||
|
if not isinstance(text, str):
|
||||||
|
raise TypeError("atomic_write_text expects a string")
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{os.getpid()}"
|
tmp = f"{path}.tmp.{os.getpid()}"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ class AuthManager:
|
|||||||
)
|
)
|
||||||
old_user = "admin"
|
old_user = "admin"
|
||||||
old_hash = self._config["password_hash"]
|
old_hash = self._config["password_hash"]
|
||||||
|
with self._config_lock:
|
||||||
self._config = {
|
self._config = {
|
||||||
"users": {
|
"users": {
|
||||||
old_user: {
|
old_user: {
|
||||||
@@ -204,6 +205,7 @@ class AuthManager:
|
|||||||
continue
|
continue
|
||||||
normalized[key] = data
|
normalized[key] = data
|
||||||
if removed or normalized != users:
|
if removed or normalized != users:
|
||||||
|
with self._config_lock:
|
||||||
self._config["users"] = normalized
|
self._config["users"] = normalized
|
||||||
self._save()
|
self._save()
|
||||||
if removed:
|
if removed:
|
||||||
|
|||||||
+216
-10
@@ -3,13 +3,16 @@ import logging
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
|
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine, make_url
|
||||||
from sqlalchemy.types import TypeDecorator
|
from sqlalchemy.types import TypeDecorator
|
||||||
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||||
from sqlalchemy.orm import relationship, sessionmaker, backref
|
from sqlalchemy.orm import relationship, sessionmaker, backref
|
||||||
|
|
||||||
from src.runtime_paths import get_app_root
|
from src.runtime_paths import get_app_root
|
||||||
|
from core.platform_compat import safe_chmod, IS_WINDOWS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,12 +45,28 @@ def _default_database_url() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_sqlite_url(url: str) -> str:
|
def _normalize_sqlite_url(url: str) -> str:
|
||||||
if not url.startswith("sqlite:///"):
|
"""Resolve relative ordinary SQLite paths without rewriting URI filenames."""
|
||||||
|
try:
|
||||||
|
parsed = make_url(url)
|
||||||
|
except Exception:
|
||||||
return url
|
return url
|
||||||
db_path = url.replace("sqlite:///", "", 1)
|
|
||||||
if db_path == ":memory:" or os.path.isabs(db_path):
|
if parsed.get_backend_name() != "sqlite":
|
||||||
return url
|
return url
|
||||||
return f"sqlite:///{(Path(get_app_root()) / db_path).resolve().as_posix()}"
|
|
||||||
|
db_path = parsed.database
|
||||||
|
if (
|
||||||
|
not db_path
|
||||||
|
or db_path == ":memory:"
|
||||||
|
or str(db_path).lower().startswith("file:")
|
||||||
|
or os.path.isabs(str(db_path))
|
||||||
|
):
|
||||||
|
return url
|
||||||
|
|
||||||
|
absolute_path = (Path(get_app_root()) / str(db_path)).resolve().as_posix()
|
||||||
|
return parsed.set(database=absolute_path).render_as_string(
|
||||||
|
hide_password=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Get database URL from environment, default to SQLite in DATA_DIR
|
# Get database URL from environment, default to SQLite in DATA_DIR
|
||||||
@@ -59,6 +78,59 @@ engine = create_engine(
|
|||||||
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
|
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Sidecar files SQLite can create next to the main DB. -journal is the default
|
||||||
|
# rollback journal; -wal/-shm appear once WAL is enabled. Each can hold copies of
|
||||||
|
# secret-bearing pages, so they get the same 0o600 lockdown as the DB itself.
|
||||||
|
_SQLITE_SIDECARS = ("-journal", "-wal", "-shm")
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_db_path(url) -> Optional[str]:
|
||||||
|
"""Return the filesystem path for a file-backed SQLite URL.
|
||||||
|
|
||||||
|
SQLite query parameters such as ``mode=memory`` only affect filename
|
||||||
|
semantics when SQLAlchemy enables URI handling with ``uri=true``. Ordinary
|
||||||
|
file URLs must therefore remain file-backed even when they contain a query
|
||||||
|
parameter named ``mode``.
|
||||||
|
|
||||||
|
For SQLite ``file:`` URIs, an empty authority or ``localhost`` identifies a
|
||||||
|
local path. Other authorities are retained as UNC-style paths.
|
||||||
|
"""
|
||||||
|
if url.get_backend_name() != "sqlite":
|
||||||
|
return None
|
||||||
|
|
||||||
|
db_path = url.database
|
||||||
|
if not db_path or db_path == ":memory:":
|
||||||
|
return None
|
||||||
|
|
||||||
|
db_path = str(db_path)
|
||||||
|
query = {
|
||||||
|
str(key).lower(): str(value).strip().lower()
|
||||||
|
for key, value in dict(getattr(url, "query", {}) or {}).items()
|
||||||
|
}
|
||||||
|
uri_enabled = query.get("uri") in {"1", "true", "yes", "on"}
|
||||||
|
is_file_uri = db_path.lower().startswith("file:")
|
||||||
|
|
||||||
|
if not uri_enabled or not is_file_uri:
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
if (
|
||||||
|
db_path.lower().startswith("file::memory:")
|
||||||
|
or query.get("mode") == "memory"
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed = urlparse(db_path)
|
||||||
|
fs_path = parsed.path or ""
|
||||||
|
if not fs_path or fs_path == ":memory:":
|
||||||
|
return None
|
||||||
|
|
||||||
|
authority = parsed.netloc
|
||||||
|
if authority and authority.lower() != "localhost":
|
||||||
|
fs_path = f"//{authority}{fs_path}"
|
||||||
|
|
||||||
|
return unquote(fs_path)
|
||||||
|
|
||||||
# Create session factory
|
# Create session factory
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
@@ -276,6 +348,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 +1255,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 +1766,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
|
||||||
@@ -1794,6 +1891,41 @@ def init_db():
|
|||||||
"""
|
"""
|
||||||
_migrate_model_endpoints()
|
_migrate_model_endpoints()
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
# Lock the DB file (and any SQLite sidecars) to 0o600 — it holds bearer-token
|
||||||
|
# + bcrypt hashes and encrypted provider keys. POSIX only; safe_chmod no-ops
|
||||||
|
# on Windows (ACL-restricted profile dir) and the path helper returns None for
|
||||||
|
# Postgres / in-memory. Must stay AFTER create_all: the file is born here at
|
||||||
|
# the umask default, and nothing below resets the mode. The path comes from
|
||||||
|
# engine.url (SQLAlchemy's parsed URL), so a driver-qualified or query-tagged
|
||||||
|
# DATABASE_URL still resolves to the real file instead of slipping through.
|
||||||
|
db_path = _sqlite_db_path(engine.url)
|
||||||
|
if db_path is not None:
|
||||||
|
# Fail closed-loud on the main file: this is the only access control on
|
||||||
|
# it, so if the chmod genuinely fails (read-only FS, foreign owner) an
|
||||||
|
# operator should hear about it. safe_chmod also returns False as a
|
||||||
|
# Windows no-op, so guard on IS_WINDOWS to avoid a spurious warning there.
|
||||||
|
if not safe_chmod(db_path, 0o600) and not IS_WINDOWS:
|
||||||
|
logger.warning(
|
||||||
|
"Could not restrict %s to 0o600; it holds secrets and may be "
|
||||||
|
"world-readable. Check filesystem permissions and ownership.",
|
||||||
|
db_path,
|
||||||
|
)
|
||||||
|
# Re-lock any sidecars present at startup. New ones inherit the main
|
||||||
|
# file's mode (now 0o600, since we set it first), and they're usually
|
||||||
|
# absent here, but a stale -wal/-shm/-journal left by an older 0o644
|
||||||
|
# install could still expose secret pages. Absent sidecars are the
|
||||||
|
# normal case, not an error — only a failed chmod warrants a warning.
|
||||||
|
for suffix in _SQLITE_SIDECARS:
|
||||||
|
sidecar = db_path + suffix
|
||||||
|
if (
|
||||||
|
os.path.exists(sidecar)
|
||||||
|
and not safe_chmod(sidecar, 0o600)
|
||||||
|
and not IS_WINDOWS
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Could not restrict %s to 0o600; it may expose DB pages.",
|
||||||
|
sidecar,
|
||||||
|
)
|
||||||
_migrate_add_hidden_models_column()
|
_migrate_add_hidden_models_column()
|
||||||
_migrate_add_cached_models_column()
|
_migrate_add_cached_models_column()
|
||||||
_migrate_add_pinned_models_column()
|
_migrate_add_pinned_models_column()
|
||||||
@@ -1811,6 +1943,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 +1966,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()
|
||||||
@@ -1877,6 +2011,20 @@ def _migrate_chat_messages_fts():
|
|||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
|
fts_content_expr_new = (
|
||||||
|
"CASE WHEN instr(COALESCE(new.content, ''), ';base64,') > 0 "
|
||||||
|
"OR instr(COALESCE(new.content, ''), 'data:image/') > 0 "
|
||||||
|
"OR instr(COALESCE(new.content, ''), 'data:audio/') > 0 "
|
||||||
|
"THEN '[inline media omitted from search index]' "
|
||||||
|
"ELSE COALESCE(new.content, '') END"
|
||||||
|
)
|
||||||
|
fts_content_expr_cm = (
|
||||||
|
"CASE WHEN instr(COALESCE(cm.content, ''), ';base64,') > 0 "
|
||||||
|
"OR instr(COALESCE(cm.content, ''), 'data:image/') > 0 "
|
||||||
|
"OR instr(COALESCE(cm.content, ''), 'data:audio/') > 0 "
|
||||||
|
"THEN '[inline media omitted from search index]' "
|
||||||
|
"ELSE COALESCE(cm.content, '') END"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp._odysseus_fts5_probe USING fts5(content)")
|
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp._odysseus_fts5_probe USING fts5(content)")
|
||||||
conn.execute("DROP TABLE IF EXISTS temp._odysseus_fts5_probe")
|
conn.execute("DROP TABLE IF EXISTS temp._odysseus_fts5_probe")
|
||||||
@@ -1885,7 +2033,7 @@ def _migrate_chat_messages_fts():
|
|||||||
return
|
return
|
||||||
|
|
||||||
conn.executescript(
|
conn.executescript(
|
||||||
"""
|
f"""
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5(
|
||||||
content,
|
content,
|
||||||
message_id UNINDEXED,
|
message_id UNINDEXED,
|
||||||
@@ -1893,10 +2041,14 @@ def _migrate_chat_messages_fts():
|
|||||||
role UNINDEXED
|
role UNINDEXED
|
||||||
);
|
);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS chat_messages_fts_ai;
|
||||||
|
DROP TRIGGER IF EXISTS chat_messages_fts_ad;
|
||||||
|
DROP TRIGGER IF EXISTS chat_messages_fts_au;
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ai
|
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ai
|
||||||
AFTER INSERT ON chat_messages BEGIN
|
AFTER INSERT ON chat_messages BEGIN
|
||||||
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
||||||
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role);
|
VALUES ({fts_content_expr_new}, new.id, new.session_id, new.role);
|
||||||
END;
|
END;
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ad
|
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ad
|
||||||
@@ -1908,14 +2060,14 @@ def _migrate_chat_messages_fts():
|
|||||||
AFTER UPDATE ON chat_messages BEGIN
|
AFTER UPDATE ON chat_messages BEGIN
|
||||||
DELETE FROM chat_messages_fts WHERE message_id = old.id;
|
DELETE FROM chat_messages_fts WHERE message_id = old.id;
|
||||||
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
||||||
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role);
|
VALUES ({fts_content_expr_new}, new.id, new.session_id, new.role);
|
||||||
END;
|
END;
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
f"""
|
||||||
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
||||||
SELECT COALESCE(cm.content, ''), cm.id, cm.session_id, cm.role
|
SELECT {fts_content_expr_cm}, cm.id, cm.session_id, cm.role
|
||||||
FROM chat_messages cm
|
FROM chat_messages cm
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM chat_messages_fts fts
|
SELECT 1 FROM chat_messages_fts fts
|
||||||
@@ -1923,6 +2075,7 @@ def _migrate_chat_messages_fts():
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
_scrub_legacy_chat_message_fts_media(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger(__name__).warning(f"chat_messages FTS migration failed: {e}")
|
logging.getLogger(__name__).warning(f"chat_messages FTS migration failed: {e}")
|
||||||
@@ -1933,6 +2086,37 @@ def _migrate_chat_messages_fts():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_legacy_chat_message_fts_media(conn) -> None:
|
||||||
|
"""Replace already-indexed inline media rows with searchable text only."""
|
||||||
|
try:
|
||||||
|
from src.attachment_refs import search_index_text
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).warning(f"chat_messages FTS media scrub skipped: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, session_id, role, content
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE instr(COALESCE(content, ''), ';base64,') > 0
|
||||||
|
OR instr(COALESCE(content, ''), 'data:image/') > 0
|
||||||
|
OR instr(COALESCE(content, ''), 'data:audio/') > 0
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for message_id, session_id, role, content in rows:
|
||||||
|
conn.execute("DELETE FROM chat_messages_fts WHERE message_id = ?", (message_id,))
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(search_index_text(content), message_id, session_id, role),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).warning(f"chat_messages FTS media scrub failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _migrate_add_email_smtp_security():
|
def _migrate_add_email_smtp_security():
|
||||||
"""Add explicit SMTP security mode for Proton Bridge/custom local SMTP."""
|
"""Add explicit SMTP security mode for Proton Bridge/custom local SMTP."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -2184,6 +2368,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
@@ -1,4 +1,4 @@
|
|||||||
# src/exceptions.py
|
# core/exceptions.py
|
||||||
"""Custom exceptions for the application."""
|
"""Custom exceptions for the application."""
|
||||||
|
|
||||||
class SessionNotFoundError(Exception):
|
class SessionNotFoundError(Exception):
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Helpers for keeping sensitive data out of logs.
|
||||||
|
|
||||||
|
Endpoint URLs configured by admins can embed credentials in the userinfo
|
||||||
|
(``https://user:pass@host``) or query string (``?api_key=...``). Logging them
|
||||||
|
raw leaks those secrets, so route/diagnostic logs run URLs through
|
||||||
|
``redact_url`` first. Reconstructing the URL without userinfo/query/fragment
|
||||||
|
also doubles as a sanitizer barrier for CodeQL's clear-text-logging query.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
|
|
||||||
|
def redact_url(url: str) -> str:
|
||||||
|
"""Return a URL safe for logs by removing userinfo and query/fragment.
|
||||||
|
|
||||||
|
Keeps scheme, host, port and path so logs stay useful for debugging.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url or "")
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if ":" in host: # IPv6 literal — re-bracket so host:port stays unambiguous
|
||||||
|
host = f"[{host}]"
|
||||||
|
if parsed.port:
|
||||||
|
host = f"{host}:{parsed.port}"
|
||||||
|
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
|
||||||
|
except Exception:
|
||||||
|
return "<endpoint>"
|
||||||
+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'; "
|
||||||
|
|||||||
+55
-18
@@ -16,6 +16,8 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
|
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
|
||||||
from .models import Session, ChatMessage
|
from .models import Session, ChatMessage
|
||||||
|
from src.attachment_refs import persistable_message_content
|
||||||
|
from src.upload_handler import reserve_message_upload_references
|
||||||
|
|
||||||
# Re-export singleton accessors from models for convenience
|
# Re-export singleton accessors from models for convenience
|
||||||
from .models import set_session_manager_instance, get_session_manager_instance
|
from .models import set_session_manager_instance, get_session_manager_instance
|
||||||
@@ -40,7 +42,18 @@ def _parse_msg_content(raw):
|
|||||||
if isinstance(raw, str) and raw.startswith('[{') and '"type"' in raw:
|
if isinstance(raw, str) and raw.startswith('[{') and '"type"' in raw:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
if isinstance(parsed, list) and all(isinstance(p, dict) for p in parsed):
|
# Only treat as serialized multimodal content when EVERY element is
|
||||||
|
# a dict whose "type" is a recognized content-block kind. Otherwise a
|
||||||
|
# plain text message that merely *looks* like a JSON array of objects
|
||||||
|
# (e.g. a user pasting an API schema/sample with a "type" field) was
|
||||||
|
# silently parsed back into a list, destroying the original string.
|
||||||
|
_BLOCK_TYPES = {
|
||||||
|
"text", "image", "image_url", "audio", "input_audio",
|
||||||
|
"input_image", "document", "file",
|
||||||
|
}
|
||||||
|
if (isinstance(parsed, list) and parsed
|
||||||
|
and all(isinstance(p, dict) and p.get("type") in _BLOCK_TYPES
|
||||||
|
for p in parsed)):
|
||||||
return parsed
|
return parsed
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
pass
|
pass
|
||||||
@@ -61,6 +74,7 @@ class SessionManager:
|
|||||||
def __init__(self, sessions_file: str = None):
|
def __init__(self, sessions_file: str = None):
|
||||||
# sessions_file kept for backward compat, not used
|
# sessions_file kept for backward compat, not used
|
||||||
self.sessions: Dict[str, Session] = {}
|
self.sessions: Dict[str, Session] = {}
|
||||||
|
self.upload_handler = None
|
||||||
self.load_sessions()
|
self.load_sessions()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -219,17 +233,26 @@ class SessionManager:
|
|||||||
logger.warning("Dropping message for deleted session %s", session_id)
|
logger.warning("Dropping message for deleted session %s", session_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
missing_upload_id = reserve_message_upload_references(
|
||||||
|
getattr(self, "upload_handler", None),
|
||||||
|
getattr(db_session, "owner", None),
|
||||||
|
message.content,
|
||||||
|
message.metadata,
|
||||||
|
)
|
||||||
|
if missing_upload_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Referenced upload is no longer available: {missing_upload_id}"
|
||||||
|
)
|
||||||
|
|
||||||
msg_id = str(uuid.uuid4())
|
msg_id = str(uuid.uuid4())
|
||||||
msg_time = datetime.utcnow()
|
msg_time = datetime.utcnow()
|
||||||
if message.metadata is None:
|
if message.metadata is None:
|
||||||
message.metadata = {}
|
message.metadata = {}
|
||||||
message.metadata.setdefault('timestamp', _message_timestamp_iso(msg_time))
|
message.metadata.setdefault('timestamp', _message_timestamp_iso(msg_time))
|
||||||
# Multimodal content (image/audio attachments) is a list — serialize
|
# Multimodal content may contain provider data URLs for the live
|
||||||
# to JSON so the Text column can store it. On reload, _db_to_session
|
# model call. Persist only readable text plus attachment references
|
||||||
# detects the JSON-array prefix and parses it back.
|
# so chat_messages/FTS do not duplicate upload bytes.
|
||||||
_content = message.content
|
_content = persistable_message_content(message.content, message.metadata)
|
||||||
if isinstance(_content, list):
|
|
||||||
_content = json.dumps(_content)
|
|
||||||
db_message = DbChatMessage(
|
db_message = DbChatMessage(
|
||||||
id=msg_id,
|
id=msg_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -311,6 +334,28 @@ class SessionManager:
|
|||||||
session = self.get_session(session_id)
|
session = self.get_session(session_id)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||||
|
if db_session is None:
|
||||||
|
logger.warning("Cannot replace history for missing session %s", session_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Reserve every incoming attachment before removing any durable
|
||||||
|
# message row. reserve_upload() shares the upload lifecycle lock
|
||||||
|
# with cleanup, so an upload cannot be deleted between this
|
||||||
|
# ownership check/access touch and the replacement transaction.
|
||||||
|
# A failed reservation must leave the existing transcript intact.
|
||||||
|
for message in messages:
|
||||||
|
missing_upload_id = reserve_message_upload_references(
|
||||||
|
getattr(self, "upload_handler", None),
|
||||||
|
getattr(db_session, "owner", None),
|
||||||
|
message.content,
|
||||||
|
message.metadata,
|
||||||
|
)
|
||||||
|
if missing_upload_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Referenced upload is no longer available: {missing_upload_id}"
|
||||||
|
)
|
||||||
|
|
||||||
db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete()
|
db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
for i, message in enumerate(messages):
|
for i, message in enumerate(messages):
|
||||||
@@ -319,15 +364,9 @@ class SessionManager:
|
|||||||
id=msg_id,
|
id=msg_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
role=message.role,
|
role=message.role,
|
||||||
# Multimodal content (image/audio attachments) is a list;
|
# Mirrors _persist_message: keep raw media bytes out of the
|
||||||
# serialize to JSON so the Text column round-trips via
|
# persisted transcript and search index.
|
||||||
# _parse_msg_content. Storing the raw list let SQLAlchemy
|
content=persistable_message_content(message.content, message.metadata),
|
||||||
# bind its single-quoted repr, which _parse_msg_content
|
|
||||||
# cannot parse (it looks for double-quoted "type"), so the
|
|
||||||
# attachment was destroyed on reload. Mirrors _persist_message.
|
|
||||||
content=(json.dumps(message.content)
|
|
||||||
if isinstance(message.content, list)
|
|
||||||
else message.content),
|
|
||||||
meta_data=json.dumps(message.metadata) if message.metadata else None,
|
meta_data=json.dumps(message.metadata) if message.metadata else None,
|
||||||
timestamp=now + timedelta(microseconds=i),
|
timestamp=now + timedelta(microseconds=i),
|
||||||
)
|
)
|
||||||
@@ -336,8 +375,6 @@ class SessionManager:
|
|||||||
message.metadata = {}
|
message.metadata = {}
|
||||||
message.metadata["_db_id"] = msg_id
|
message.metadata["_db_id"] = msg_id
|
||||||
|
|
||||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
|
||||||
if db_session:
|
|
||||||
db_session.message_count = len(messages)
|
db_session.message_count = len(messages)
|
||||||
db_session.updated_at = now
|
db_session.updated_at = now
|
||||||
db_session.last_accessed = now
|
db_session.last_accessed = now
|
||||||
|
|||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build patched wheels for Real-ESRGAN's unmaintained dependencies.
|
||||||
|
#
|
||||||
|
# basicsr / gfpgan / facexlib (xinntao, last released 2022) read their version
|
||||||
|
# in setup.py with:
|
||||||
|
#
|
||||||
|
# exec(compile(f.read(), version_file, 'exec'))
|
||||||
|
# return locals()['__version__']
|
||||||
|
#
|
||||||
|
# Python 3.13+ implements PEP 667: locals() inside a function returns an
|
||||||
|
# independent snapshot that exec() can no longer mutate, so the read raises
|
||||||
|
# `KeyError: '__version__'` and the sdist build fails. That is why the Cookbook
|
||||||
|
# "install realesrgan" button dies on the python:3.14 image. The packages have
|
||||||
|
# no fixed release, so we patch get_version() to exec into an explicit namespace
|
||||||
|
# dict (works on every Python) and build wheels from the patched source.
|
||||||
|
#
|
||||||
|
# Usage: build-realesrgan-wheels.sh [OUTPUT_DIR] (default: /wheels)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OUT="${1:-/wheels}"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
cd "$work"
|
||||||
|
|
||||||
|
# Pinned to the versions Real-ESRGAN 0.3.0 resolves to.
|
||||||
|
SPECS="basicsr==1.4.2 gfpgan==1.3.8 facexlib==0.3.0"
|
||||||
|
|
||||||
|
for spec in $SPECS; do
|
||||||
|
name="${spec%%==*}"
|
||||||
|
ver="${spec##*==}"
|
||||||
|
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||||
|
# sdist URL from the PyPI JSON API instead.
|
||||||
|
url="$(python - "$name" "$ver" <<'PY'
|
||||||
|
import json, sys, urllib.request
|
||||||
|
name, ver = sys.argv[1], sys.argv[2]
|
||||||
|
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
||||||
|
for f in data["urls"]:
|
||||||
|
if f["packagetype"] == "sdist":
|
||||||
|
print(f["url"]); break
|
||||||
|
else:
|
||||||
|
sys.exit(f"no sdist found for {name}=={ver}")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
echo ">> fetching ${name} ${ver}: ${url}"
|
||||||
|
curl -fsSL "$url" -o "${name}.tar.gz"
|
||||||
|
tar xzf "${name}.tar.gz"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ">> patching get_version()"
|
||||||
|
python - <<'PY'
|
||||||
|
import pathlib
|
||||||
|
old_exec = "exec(compile(f.read(), version_file, 'exec'))"
|
||||||
|
new_exec = "_ver_ns = {}\n exec(compile(f.read(), version_file, 'exec'), _ver_ns)"
|
||||||
|
old_ret = "return locals()['__version__']"
|
||||||
|
new_ret = "return _ver_ns['__version__']"
|
||||||
|
patched = 0
|
||||||
|
for setup in pathlib.Path(".").glob("*/setup.py"):
|
||||||
|
s = setup.read_text()
|
||||||
|
if old_exec in s and old_ret in s:
|
||||||
|
setup.write_text(s.replace(old_exec, new_exec).replace(old_ret, new_ret))
|
||||||
|
print(" patched", setup)
|
||||||
|
patched += 1
|
||||||
|
assert patched == 3, f"expected to patch 3 setup.py files, patched {patched}"
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo ">> building wheels into ${OUT}"
|
||||||
|
pip wheel --no-deps -w "$OUT" ./basicsr-* ./gfpgan-* ./facexlib-*
|
||||||
|
ls -l "$OUT"
|
||||||
+24
-2
@@ -26,6 +26,27 @@ if ! getent passwd "$PUID" >/dev/null 2>&1; then
|
|||||||
useradd -u "$PUID" -g "$PGID" -M -s /bin/sh -d /app odysseus
|
useradd -u "$PUID" -g "$PGID" -M -s /bin/sh -d /app odysseus
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ODY_USER="$(getent passwd "$PUID" | cut -d: -f1)"
|
||||||
|
[ -z "$ODY_USER" ] && ODY_USER=odysseus
|
||||||
|
|
||||||
|
# Docker-socket group plumbing for the explicit host-Docker overlay. When
|
||||||
|
# opted in, the socket is owned by root:<host docker gid>. Add the app user
|
||||||
|
# to that group and later call gosu by username so supplementary groups are
|
||||||
|
# retained.
|
||||||
|
DOCKER_SOCK="${DOCKER_SOCK:-/var/run/docker.sock}"
|
||||||
|
if [ "${ODYSSEUS_ENABLE_HOST_DOCKER:-}" = "true" ] && [ -S "$DOCKER_SOCK" ]; then
|
||||||
|
SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK" 2>/dev/null || echo '')"
|
||||||
|
if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "0" ]; then
|
||||||
|
if ! getent group "$SOCK_GID" >/dev/null 2>&1; then
|
||||||
|
groupadd -g "$SOCK_GID" docker_host || true
|
||||||
|
fi
|
||||||
|
SOCK_GROUP="$(getent group "$SOCK_GID" | cut -d: -f1)"
|
||||||
|
if [ -n "$SOCK_GROUP" ]; then
|
||||||
|
usermod -aG "$SOCK_GROUP" "$ODY_USER" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mount_root_for() {
|
mount_root_for() {
|
||||||
awk -v target="$1" '$5 == target { print $4; exit }' /proc/self/mountinfo 2>/dev/null || true
|
awk -v target="$1" '$5 == target { print $4; exit }' /proc/self/mountinfo 2>/dev/null || true
|
||||||
}
|
}
|
||||||
@@ -103,6 +124,7 @@ for cu in \
|
|||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Disable the FlashInfer JIT sampler unconditionally — it is sampler-only
|
# Disable the FlashInfer JIT sampler unconditionally — it is sampler-only
|
||||||
# and has no impact on the attention path, but requires nvcc + matching
|
# and has no impact on the attention path, but requires nvcc + matching
|
||||||
# CUDA headers at startup. Without this, vLLM crashes with "Could not find
|
# CUDA headers at startup. Without this, vLLM crashes with "Could not find
|
||||||
@@ -116,9 +138,9 @@ export PATH="/app/.local/bin:$PATH"
|
|||||||
# Run first-time setup as the app user so data/ files get the right ownership.
|
# Run first-time setup as the app user so data/ files get the right ownership.
|
||||||
# setup.py is idempotent — skips auth.json / .env if they already exist.
|
# setup.py is idempotent — skips auth.json / .env if they already exist.
|
||||||
# || true so a setup failure never prevents the container from starting.
|
# || true so a setup failure never prevents the container from starting.
|
||||||
"$GOSU_BIN" "$PUID:$PGID" "$PYTHON_BIN" /app/setup.py || true
|
"$GOSU_BIN" "$ODY_USER" "$PYTHON_BIN" /app/setup.py || true
|
||||||
|
|
||||||
# Drop root and run the actual app. `gosu` is preferred over `su` /
|
# Drop root and run the actual app. `gosu` is preferred over `su` /
|
||||||
# `sudo` because it cleans up the process tree (no extra shell layer)
|
# `sudo` because it cleans up the process tree (no extra shell layer)
|
||||||
# so signals (SIGTERM from `docker stop`) reach uvicorn directly.
|
# so signals (SIGTERM from `docker stop`) reach uvicorn directly.
|
||||||
exec "$GOSU_BIN" "$PUID:$PGID" "$@"
|
exec "$GOSU_BIN" "$ODY_USER" "$@"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Attachment References and Upload Storage
|
||||||
|
|
||||||
|
Odysseus stores uploaded bytes once under the configured upload directory and
|
||||||
|
passes stable references through chat history, tools, and future artifact work.
|
||||||
|
The goal is to avoid duplicating large inline media payloads in
|
||||||
|
`chat_messages.content` or the SQLite FTS index.
|
||||||
|
|
||||||
|
## Reference Shape
|
||||||
|
|
||||||
|
Attachment references use this minimum shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "attachment_ref",
|
||||||
|
"attachment_id": "32hex-or-32hex.ext",
|
||||||
|
"name": "original-filename.png",
|
||||||
|
"mime": "image/png",
|
||||||
|
"size": 12345,
|
||||||
|
"checksum_sha256": "hex-digest",
|
||||||
|
"created_at": "2026-07-09T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional fields such as `width`, `height`, `vision`, `vision_model`, and
|
||||||
|
`gallery_id` may be present when the uploader or preprocessing path knows them.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
The live model call may still receive provider-specific multimodal blocks for
|
||||||
|
the current turn. Persistence is different:
|
||||||
|
|
||||||
|
- `chat_messages.content` stores readable text plus compact attachment reference
|
||||||
|
lines, never raw `data:*;base64,...` upload bytes.
|
||||||
|
- `chat_messages.metadata.attachments` stores structured attachment reference
|
||||||
|
metadata for UI reloads and future processing.
|
||||||
|
- The SQLite FTS migration recreates chat-message FTS triggers so new rows do
|
||||||
|
not index inline media payloads, and it scrubs legacy rows that were already
|
||||||
|
indexed with data URLs.
|
||||||
|
|
||||||
|
## Tool Access
|
||||||
|
|
||||||
|
Agent/tool context receives upload entries as `attachment_ref` manifests with an
|
||||||
|
`odysseus://attachment/<id>` URI and `read_policy: "owner_checked_upload"`.
|
||||||
|
|
||||||
|
For compatibility with existing built-in tools, a local `path` may be included
|
||||||
|
only after all of these checks pass:
|
||||||
|
|
||||||
|
- the upload ID resolves through `UploadHandler.resolve_upload`;
|
||||||
|
- the requested owner is allowed to read the upload;
|
||||||
|
- the file remains inside the configured upload directory;
|
||||||
|
- the file path is inside the tool-readable roots.
|
||||||
|
|
||||||
|
External MCP/custom tools should treat the URI and attachment ID as the stable
|
||||||
|
contract and request bytes through an owner-checked server path, not by assuming
|
||||||
|
host filesystem layout.
|
||||||
|
|
||||||
|
## Retention and Deletion
|
||||||
|
|
||||||
|
Current retention behavior is conservative:
|
||||||
|
|
||||||
|
- uploads are indexed in `uploads.json` with owner, checksum, MIME type, size,
|
||||||
|
and creation time;
|
||||||
|
- admin cleanup first scans persisted chat metadata/content, document versions,
|
||||||
|
PDF source markers, gallery hashes, notes, and calendar records for live
|
||||||
|
references;
|
||||||
|
- cleanup fails closed if that reference scan cannot complete, and the lower-level
|
||||||
|
cleanup API removes nothing unless it receives a complete reference snapshot;
|
||||||
|
- expired, unreferenced uploads are removed during the completed scan, while
|
||||||
|
attachment-bearing writers must first take an owner-checked reservation that
|
||||||
|
serializes with deletion and refreshes the upload's access timestamp;
|
||||||
|
- deliberate removal atomically drops matching `uploads.json` rows before deleting
|
||||||
|
the bytes and restores those rows if filesystem removal fails;
|
||||||
|
- deleting a chat removes the chat rows but does not immediately delete shared
|
||||||
|
upload bytes, because the same upload may also be referenced by gallery items,
|
||||||
|
documents, duplicate-upload rows, or future artifact records.
|
||||||
|
|
||||||
|
There is no distinct artifact table in the current schema. Artifact-like upload
|
||||||
|
references persisted in chat or document text are covered by the canonical
|
||||||
|
attachment-ID scan; any future artifact store must be added to reference discovery
|
||||||
|
before cleanup is allowed to consider its uploads unreferenced.
|
||||||
|
|
||||||
|
Cleanup and write reservations share the upload-index lock. This closes the
|
||||||
|
scan/write/delete race in the documented single-worker deployment; a future
|
||||||
|
multi-process deployment must add an inter-process lock or move lifecycle state
|
||||||
|
into the database before enabling destructive cleanup in more than one worker.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 79 KiB |
+97
-2
@@ -15,7 +15,7 @@ On first setup, Odysseus creates an admin account (`admin` unless
|
|||||||
For Docker installs, the same line is in `docker compose logs odysseus`.
|
For Docker installs, the same line is in `docker compose logs odysseus`.
|
||||||
Use that for the first login, then change it in **Settings**.
|
Use that for the first login, then change it in **Settings**.
|
||||||
|
|
||||||
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
|
Contributing? See [CONTRIBUTING.md](../CONTRIBUTING.md) for setup, testing, and
|
||||||
pull request guidelines.
|
pull request guidelines.
|
||||||
|
|
||||||
### Docker (recommended)
|
### Docker (recommended)
|
||||||
@@ -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
|
||||||
@@ -123,6 +150,51 @@ scripts/check-docker-gpu.sh --enable-nvidia-overlay
|
|||||||
# Full assisted setup — install toolkit, then enable overlay if passthrough works:
|
# Full assisted setup — install toolkit, then enable overlay if passthrough works:
|
||||||
scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
|
scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
|
||||||
```
|
```
|
||||||
|
#### Arch Linux NVIDIA Docker notes
|
||||||
|
|
||||||
|
On Arch Linux, verify the host NVIDIA driver and Docker GPU passthrough before enabling the Odysseus NVIDIA overlay.
|
||||||
|
|
||||||
|
Install the required packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -Syu
|
||||||
|
sudo pacman -S docker docker-compose nvidia-container-toolkit nvidia-utils
|
||||||
|
sudo systemctl enable --now docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure Docker to use the NVIDIA container runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nvidia-ctk runtime configure --runtime=docker
|
||||||
|
sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the host GPU:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify Docker GPU passthrough:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --gpus all nvidia/cuda:12.9.0-base-ubuntu22.04 nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
Then enable the Odysseus NVIDIA compose overlay:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Rebuild and verify the GPU inside the Odysseus container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec odysseus nvidia-smi -L
|
||||||
|
```
|
||||||
|
|
||||||
|
For first-time local model testing on 8 GB laptop GPUs, start with GGUF/Q4 models on llama.cpp before trying GPTQ/AWQ models on vLLM or SGLang. This keeps the first run simpler while confirming GPU passthrough works.
|
||||||
|
|
||||||
Safety notes:
|
Safety notes:
|
||||||
- The app never installs host GPU runtime automatically.
|
- The app never installs host GPU runtime automatically.
|
||||||
@@ -250,6 +322,19 @@ python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
|||||||
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
||||||
3.11+ version) for the venv step.
|
3.11+ version) for the venv step.
|
||||||
|
|
||||||
|
**Exposing on a LAN/Tailscale (Windows):** the launcher binds to `127.0.0.1` and
|
||||||
|
does **not** read `APP_BIND` / `ODYSSEUS_HOST` from `.env`, so editing `.env`
|
||||||
|
alone leaves the native Windows server on loopback. Pass the launcher's
|
||||||
|
`-BindHost` flag instead:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1 -BindHost 0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The manual `uvicorn` command takes the same address as `--host 0.0.0.0`. Bind
|
||||||
|
outside loopback only for a trusted LAN/VPN such as Tailscale: keep
|
||||||
|
`AUTH_ENABLED=true` and do not expose the port directly to the public internet.
|
||||||
|
|
||||||
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
||||||
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
||||||
model downloads and the agent shell tool, also install
|
model downloads and the agent shell tool, also install
|
||||||
@@ -286,6 +371,16 @@ To expose Odysseus on a local network or Tailscale with HTTPS:
|
|||||||
```
|
```
|
||||||
4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings).
|
4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings).
|
||||||
|
|
||||||
|
### Common self-host traps (30-second fixes)
|
||||||
|
A grab-bag of small gotchas that otherwise turn into long debugging sessions.
|
||||||
|
|
||||||
|
- **`AUTH_ENABLED=false` is ignored / you're still forced to log in (Windows).** If you edited `.env` in Notepad it may have saved a UTF-8 **BOM**, turning the first key into `AUTH_ENABLED` so it is never matched. Odysseus loads `.env` with `encoding="utf-8-sig"` to tolerate a leading BOM, but the safe fix is to re-save `.env` as **UTF-8 without BOM** (VS Code: *Save with Encoding → UTF-8*).
|
||||||
|
- **macOS: the app isn't at `http://localhost:7000`.** macOS AirPlay Receiver usually holds port `7000`, so the macOS start script serves on **`7860`** instead — open `http://localhost:7860`. To use `7000`, free it (System Settings → General → AirDrop & Handoff → turn off *AirPlay Receiver*) and set `APP_PORT=7000`.
|
||||||
|
- **Copy buttons do nothing over a plain-HTTP Tailscale/LAN URL.** Browsers only expose the clipboard API (`navigator.clipboard`) on **secure origins** — HTTPS, or `localhost`. Over `http://100.x.y.z:7860` it is blocked. Serve over HTTPS (see *HTTPS + LAN/Tailscale exposure* above); `localhost` is exempt, so copy still works on the host itself.
|
||||||
|
- **Self-hosted ntfy reminders don't reach your phone.** Two things: (1) the bundled ntfy binds to loopback by default — to reach it from your phone set `NTFY_BIND` to your host/Tailscale IP and `NTFY_BASE_URL` to the same server URL in `.env`, then recreate the ntfy container (see the `NTFY_*` block in `.env.example`); (2) in the ntfy **Android** app, subscribe to the topic with **Instant delivery** enabled — non-`ntfy.sh` servers don't get instant push otherwise.
|
||||||
|
- **Local mail (Dovecot) login fails: "Plaintext authentication disallowed on non-encrypted connections."** Your IMAP/SMTP server is refusing cleartext auth over an unencrypted link. Prefer enabling TLS on the mail server; on a trusted LAN only, you can allow cleartext (Dovecot: `disable_plaintext_auth = no`).
|
||||||
|
- **Calendar/contacts (Radicale) won't sync.** Point Odysseus at the **full collection URL** with its trailing slash — e.g. `http://host:5232/<user>/<collection-id>/` — not just the server root. Radicale shows this address for each calendar/address book in its web UI.
|
||||||
|
|
||||||
### Optional Dependencies
|
### Optional Dependencies
|
||||||
`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default.
|
`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default.
|
||||||
|
|
||||||
@@ -422,4 +517,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).
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
|
||||||
|
with Reserved Font Name OpenDyslexic.
|
||||||
|
Copyright (c) 12/2012 - 2019
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
+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.")]
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
if not model_spec:
|
if not model_spec:
|
||||||
for candidate in ("gpt-image-1.5", "gpt-image-1", "dall-e-3"):
|
for candidate in ("gpt-image-1.5", "gpt-image-1", "dall-e-3"):
|
||||||
try:
|
try:
|
||||||
_resolve_model(candidate)
|
await asyncio.to_thread(_resolve_model, candidate)
|
||||||
model_spec = candidate
|
model_spec = candidate
|
||||||
break
|
break
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -81,7 +81,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
if not model_spec:
|
if not model_spec:
|
||||||
return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")]
|
return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")]
|
||||||
|
|
||||||
url, model_id, headers = _resolve_model(model_spec)
|
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec)
|
||||||
|
|
||||||
is_gpt_image = "gpt-image" in model_id.lower()
|
is_gpt_image = "gpt-image" in model_id.lower()
|
||||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+128
-5
@@ -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
|
||||||
@@ -12,8 +13,9 @@ from sqlalchemy import or_, and_
|
|||||||
from dateutil.rrule import rrulestr
|
from dateutil.rrule import rrulestr
|
||||||
|
|
||||||
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
|
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
|
||||||
from src.auth_helpers import require_user
|
from src.auth_helpers import effective_user, require_user
|
||||||
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
|
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
|
||||||
|
from src.upload_handler import reserve_upload_references
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,6 +36,24 @@ def _ics_naive_dtstart(dt):
|
|||||||
return datetime(dt.year, dt.month, dt.day)
|
return datetime(dt.year, dt.month, dt.day)
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_positive_duration(start_dt, end_dt, all_day):
|
||||||
|
"""Clamp an imported event's end so it has a positive duration.
|
||||||
|
|
||||||
|
Some .ics exporters write a single-day all-day event with DTEND equal to
|
||||||
|
DTSTART (treating DTEND as inclusive rather than the RFC 5545 exclusive
|
||||||
|
bound). Stored verbatim that produces a zero-duration row, which the
|
||||||
|
list_events overlap filter (dtstart < end AND dtend > start) silently
|
||||||
|
drops — the event never appears on the calendar even though the web UI
|
||||||
|
would otherwise show it. Normalize a non-positive end to the same default
|
||||||
|
span used when DTEND is absent: one day for all-day events, one hour
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
if end_dt <= start_dt:
|
||||||
|
return start_dt + (timedelta(days=1) if all_day else timedelta(hours=1))
|
||||||
|
return end_dt
|
||||||
|
|
||||||
|
|
||||||
# Single-user fallback identity. Used only when:
|
# Single-user fallback identity. Used only when:
|
||||||
# 1. The app is configured for single-user (no auth middleware), AND
|
# 1. The app is configured for single-user (no auth middleware), AND
|
||||||
# 2. The request didn't resolve to an authenticated user.
|
# 2. The request didn't resolve to an authenticated user.
|
||||||
@@ -434,6 +454,20 @@ def _parse_dt(s: str) -> datetime:
|
|||||||
if t is not None:
|
if t is not None:
|
||||||
return base.replace(hour=t[0], minute=t[1])
|
return base.replace(hour=t[0], minute=t[1])
|
||||||
|
|
||||||
|
# time-first: "3pm today", "9am tomorrow", "11pm tonight"
|
||||||
|
# (parity with parse_due_for_user, which handles these via the same form)
|
||||||
|
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
||||||
|
if m:
|
||||||
|
time_part, word = m.group(1).strip(), m.group(2)
|
||||||
|
base = today
|
||||||
|
if word in ("tomorrow", "tmrw"):
|
||||||
|
base = today + timedelta(days=1)
|
||||||
|
elif word == "yesterday":
|
||||||
|
base = today - timedelta(days=1)
|
||||||
|
t = _parse_time(time_part)
|
||||||
|
if t is not None:
|
||||||
|
return base.replace(hour=t[0], minute=t[1])
|
||||||
|
|
||||||
# next <weekday> [at] TIME
|
# next <weekday> [at] TIME
|
||||||
weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
||||||
m = _re.match(r'^next\s+(\w+)(?:\s+at)?\s*(.*)$', lower)
|
m = _re.match(r'^next\s+(\w+)(?:\s+at)?\s*(.*)$', lower)
|
||||||
@@ -509,6 +543,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 ""),
|
||||||
@@ -522,6 +557,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]:
|
||||||
@@ -586,6 +643,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:
|
||||||
@@ -606,8 +664,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
|
||||||
@@ -635,9 +698,18 @@ def _expand_rrule(
|
|||||||
|
|
||||||
# ── Routes ──
|
# ── Routes ──
|
||||||
|
|
||||||
def setup_calendar_routes() -> APIRouter:
|
def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
|
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
|
||||||
|
|
||||||
|
def _reserve_calendar_uploads(request: Request, *values) -> None:
|
||||||
|
missing_id = reserve_upload_references(
|
||||||
|
upload_handler,
|
||||||
|
effective_user(request),
|
||||||
|
*values,
|
||||||
|
)
|
||||||
|
if missing_id:
|
||||||
|
raise HTTPException(409, f"Referenced upload is no longer available: {missing_id}")
|
||||||
|
|
||||||
# ── CalDAV multi-account helpers ─────────────────────────────────────────
|
# ── CalDAV multi-account helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
def _get_caldav_accounts(owner: str) -> list:
|
def _get_caldav_accounts(owner: str) -> list:
|
||||||
@@ -851,7 +923,24 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
'</d:prop></d:propfind>'
|
'</d:prop></d:propfind>'
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=8.0, follow_redirects=False, trust_env=False) as cx:
|
# Build an SSL context that trusts the operator's custom CA bundle
|
||||||
|
# (SSL_CERT_FILE / REQUESTS_CA_BUNDLE) so self-signed CalDAV servers
|
||||||
|
# pass the pre-flight the same way they pass the real sync.
|
||||||
|
# trust_env=False is kept to block proxy/auth env leakage; the CA
|
||||||
|
# bundle is loaded explicitly instead.
|
||||||
|
import ssl as _ssl
|
||||||
|
_ssl_ctx = _ssl.create_default_context()
|
||||||
|
# Disable VERIFY_X509_STRICT so certs without a keyUsage extension
|
||||||
|
# (common in self-signed setups) are accepted, matching the
|
||||||
|
# requests/urllib3 behavior used by the CalDAV sync path.
|
||||||
|
_ssl_ctx.verify_flags &= ~_ssl.VERIFY_X509_STRICT
|
||||||
|
_ca_bundle = _os.environ.get("SSL_CERT_FILE") or _os.environ.get("REQUESTS_CA_BUNDLE")
|
||||||
|
if _ca_bundle:
|
||||||
|
if _os.path.isfile(_ca_bundle):
|
||||||
|
_ssl_ctx.load_verify_locations(_ca_bundle)
|
||||||
|
else:
|
||||||
|
logger.warning("CalDAV test: CA bundle %s not found, using system CAs", _ca_bundle)
|
||||||
|
async with httpx.AsyncClient(timeout=8.0, follow_redirects=False, trust_env=False, verify=_ssl_ctx) as cx:
|
||||||
r = await cx.request(
|
r = await cx.request(
|
||||||
"PROPFIND", url,
|
"PROPFIND", url,
|
||||||
auth=(user, pw),
|
auth=(user, pw),
|
||||||
@@ -1008,6 +1097,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
@router.post("/events")
|
@router.post("/events")
|
||||||
async def create_event(request: Request, data: EventCreate):
|
async def create_event(request: Request, data: EventCreate):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
|
_reserve_calendar_uploads(request, data.color, data.description, data.location)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
cal = None
|
cal = None
|
||||||
@@ -1069,6 +1159,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
@router.put("/events/{uid}")
|
@router.put("/events/{uid}")
|
||||||
async def update_event(request: Request, uid: str, data: EventUpdate):
|
async def update_event(request: Request, uid: str, data: EventUpdate):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
|
_reserve_calendar_uploads(request, data.color, data.description, data.location)
|
||||||
try:
|
try:
|
||||||
base_uid = _resolve_base_uid(uid)
|
base_uid = _resolve_base_uid(uid)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -1118,7 +1209,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)
|
||||||
@@ -1127,7 +1218,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)
|
||||||
@@ -1147,6 +1253,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
@router.post("/calendars")
|
@router.post("/calendars")
|
||||||
async def create_calendar(request: Request, name: str = "Imported", color: str = "#5b8abf"):
|
async def create_calendar(request: Request, name: str = "Imported", color: str = "#5b8abf"):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
|
_reserve_calendar_uploads(request, color)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
cal = CalendarCal(
|
cal = CalendarCal(
|
||||||
@@ -1169,6 +1276,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
@router.put("/calendars/{cal_id}")
|
@router.put("/calendars/{cal_id}")
|
||||||
async def update_calendar(request: Request, cal_id: str, name: str = None, color: str = None):
|
async def update_calendar(request: Request, cal_id: str, name: str = None, color: str = None):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
|
_reserve_calendar_uploads(request, color)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
cal = _get_or_404_calendar(db, cal_id, owner)
|
cal = _get_or_404_calendar(db, cal_id, owner)
|
||||||
@@ -1226,7 +1334,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(target_cal)
|
db.refresh(target_cal)
|
||||||
|
|
||||||
imported = skipped = 0
|
imported = skipped = repaired = 0
|
||||||
for comp in cal_data.walk():
|
for comp in cal_data.walk():
|
||||||
if comp.name != "VEVENT":
|
if comp.name != "VEVENT":
|
||||||
continue
|
continue
|
||||||
@@ -1262,6 +1370,18 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
|
# An import predating the clamp below may have stored
|
||||||
|
# this same event with a non-positive duration, which
|
||||||
|
# the list_events overlap filter hides. Re-importing
|
||||||
|
# lands here and would skip without touching that row,
|
||||||
|
# so the event would stay invisible. Backfill the clamp
|
||||||
|
# onto the stored row before skipping it.
|
||||||
|
fixed_end = _ensure_positive_duration(
|
||||||
|
existing.dtstart, existing.dtend, bool(existing.all_day)
|
||||||
|
)
|
||||||
|
if fixed_end != existing.dtend:
|
||||||
|
existing.dtend = fixed_end
|
||||||
|
repaired += 1
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1295,6 +1415,8 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
else:
|
else:
|
||||||
end_dt = start_dt + timedelta(hours=1)
|
end_dt = start_dt + timedelta(hours=1)
|
||||||
|
|
||||||
|
end_dt = _ensure_positive_duration(start_dt, end_dt, all_day)
|
||||||
|
|
||||||
ev = CalendarEvent(
|
ev = CalendarEvent(
|
||||||
uid=uid_val,
|
uid=uid_val,
|
||||||
calendar_id=target_cal.id,
|
calendar_id=target_cal.id,
|
||||||
@@ -1315,6 +1437,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"imported": imported,
|
"imported": imported,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
|
"repaired": repaired,
|
||||||
"calendar": cal_display,
|
"calendar": cal_display,
|
||||||
"calendar_id": target_cal.id,
|
"calendar_id": target_cal.id,
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-4
@@ -14,14 +14,41 @@ 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 src.attachment_refs import attachment_ref
|
||||||
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
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CASUAL_OPENING_RE = re.compile(
|
||||||
|
r"^\s*(?:h+i+|hey+|hello+|yo+|sup+|what'?s up|wass?up|hiya|howdy|"
|
||||||
|
r"lol|lmao|haha+|hehe+|thanks?|thank you|ty|idk|dunno|meh|bruh|bro)\b(?P<tail>.*)$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_CASUAL_BLOCKLIST_RE = re.compile(
|
||||||
|
r"\b(?:cookbook|serve|serving|launch|start|vllm|sglang|llama\.?cpp|ollama|"
|
||||||
|
r"download|model|email|document|doc|note|calendar|task|search|web|research|"
|
||||||
|
r"file|folder|repo|git|settings?|endpoint|api|token|mcp)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_casual_low_signal(text: str) -> bool:
|
||||||
|
"""Short greetings/slang should not pull memory, skills, RAG, or docs."""
|
||||||
|
s = str(text or "").strip()
|
||||||
|
m = _CASUAL_OPENING_RE.match(s)
|
||||||
|
if not m:
|
||||||
|
return False
|
||||||
|
tail = m.group("tail") or ""
|
||||||
|
if _CASUAL_BLOCKLIST_RE.search(tail):
|
||||||
|
return False
|
||||||
|
tail_words = re.findall(r"[A-Za-z0-9_'-]+", tail)
|
||||||
|
return len(tail_words) <= 2
|
||||||
|
|
||||||
|
|
||||||
# Strong references to in-flight fire-and-forget tasks scheduled from this
|
# Strong references to in-flight fire-and-forget tasks scheduled from this
|
||||||
# module. asyncio only keeps weak references to tasks created via
|
# module. asyncio only keeps weak references to tasks created via
|
||||||
@@ -74,11 +101,19 @@ 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
|
||||||
# begins, so the editor pane switches to the new doc immediately.
|
# begins, so the editor pane switches to the new doc immediately.
|
||||||
auto_opened_docs: list = field(default_factory=list)
|
auto_opened_docs: list = field(default_factory=list)
|
||||||
|
# Uploads attached to this user turn, resolved and owner-checked for the
|
||||||
|
# agent's private context. This is not emitted to the browser.
|
||||||
|
uploaded_files: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ────────────────────────────────────────────────────────────── #
|
# ── Helpers ────────────────────────────────────────────────────────────── #
|
||||||
@@ -341,6 +376,62 @@ async def preprocess(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_uploaded_file_manifest(att_ids: list, upload_handler, owner: Optional[str]) -> list[dict]:
|
||||||
|
"""Resolve current-turn upload IDs into a small tool-facing manifest.
|
||||||
|
|
||||||
|
The chat UI already sends attachment ids, and preprocessing inlines as much
|
||||||
|
text as fits. Agent mode still needs a discoverable bridge for files whose
|
||||||
|
content was truncated/omitted or when the model chooses file tools. Only
|
||||||
|
owner-authorized uploads are included, and paths must remain inside the
|
||||||
|
configured upload directory.
|
||||||
|
"""
|
||||||
|
if not att_ids or not upload_handler or not hasattr(upload_handler, "resolve_upload"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _read_file_can_open(path: str) -> bool:
|
||||||
|
try:
|
||||||
|
from src.tool_execution import _resolve_tool_path
|
||||||
|
|
||||||
|
return _resolve_tool_path(path) == os.path.realpath(path)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
manifest: list[dict] = []
|
||||||
|
for att_id in att_ids:
|
||||||
|
try:
|
||||||
|
info = upload_handler.resolve_upload(str(att_id), owner=owner)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to resolve upload %r for agent manifest", att_id, exc_info=True)
|
||||||
|
continue
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = info.get("path")
|
||||||
|
if path:
|
||||||
|
try:
|
||||||
|
inside = True
|
||||||
|
if hasattr(upload_handler, "_inside_upload_dir"):
|
||||||
|
inside = bool(upload_handler._inside_upload_dir(path))
|
||||||
|
elif hasattr(upload_handler, "inside_base_dir"):
|
||||||
|
inside = bool(upload_handler.inside_base_dir(path))
|
||||||
|
if not inside or not os.path.exists(path) or not _read_file_can_open(path):
|
||||||
|
path = None
|
||||||
|
except Exception:
|
||||||
|
path = None
|
||||||
|
|
||||||
|
ref = attachment_ref({**info, "id": info.get("id") or str(att_id)})
|
||||||
|
ref.update({
|
||||||
|
"id": ref["attachment_id"],
|
||||||
|
"uri": f"odysseus://attachment/{ref['attachment_id']}",
|
||||||
|
"read_policy": "owner_checked_upload",
|
||||||
|
# Transitional compatibility: existing built-in tools can still use
|
||||||
|
# this path, but only after owner, upload-root, and tool-root checks.
|
||||||
|
"path": path,
|
||||||
|
})
|
||||||
|
manifest.append(ref)
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
|
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
|
||||||
"""Add user message to session history and update session name.
|
"""Add user message to session history and update session name.
|
||||||
In incognito mode, still add to in-memory history (for conversation context)
|
In incognito mode, still add to in-memory history (for conversation context)
|
||||||
@@ -588,6 +679,12 @@ async def build_chat_context(
|
|||||||
# bearer-token chat requests use the token owner instead of the "api" sentinel.
|
# bearer-token chat requests use the token owner instead of the "api" sentinel.
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
uprefs = load_prefs_for_user(user)
|
uprefs = load_prefs_for_user(user)
|
||||||
|
uploaded_files = build_uploaded_file_manifest(
|
||||||
|
att_ids or [],
|
||||||
|
getattr(chat_handler, "upload_handler", None),
|
||||||
|
getattr(sess, "owner", None),
|
||||||
|
)
|
||||||
|
casual_low_signal = _is_casual_low_signal(message)
|
||||||
|
|
||||||
# Memory enabled?
|
# Memory enabled?
|
||||||
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
|
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
|
||||||
@@ -597,6 +694,9 @@ async def build_chat_context(
|
|||||||
if not allow_tool_preprocessing:
|
if not allow_tool_preprocessing:
|
||||||
mem_enabled = False
|
mem_enabled = False
|
||||||
skills_enabled = False
|
skills_enabled = False
|
||||||
|
if casual_low_signal:
|
||||||
|
mem_enabled = False
|
||||||
|
skills_enabled = False
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Memory enabled=%s for user=%s (incognito=%s, no_memory=%s, pref=%s)",
|
"Memory enabled=%s for user=%s (incognito=%s, no_memory=%s, pref=%s)",
|
||||||
mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"),
|
mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"),
|
||||||
@@ -612,11 +712,11 @@ async def build_chat_context(
|
|||||||
|
|
||||||
# Use RAG?
|
# Use RAG?
|
||||||
use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True
|
use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True
|
||||||
if incognito or not allow_tool_preprocessing or is_research_spinoff:
|
if incognito or not allow_tool_preprocessing or is_research_spinoff or casual_low_signal:
|
||||||
use_rag_val = False
|
use_rag_val = False
|
||||||
|
|
||||||
# If pre-fetched search context was provided (compare mode), skip live web search
|
# If pre-fetched search context was provided (compare mode), skip live web search
|
||||||
skip_web = bool(search_context) or not allow_tool_preprocessing
|
skip_web = bool(search_context) or not allow_tool_preprocessing or casual_low_signal
|
||||||
|
|
||||||
# Build context preface
|
# Build context preface
|
||||||
# The stream path uses enhanced_message (with CoT/preprocessing applied),
|
# The stream path uses enhanced_message (with CoT/preprocessing applied),
|
||||||
@@ -635,7 +735,7 @@ async def build_chat_context(
|
|||||||
incognito=incognito,
|
incognito=incognito,
|
||||||
use_skills=skills_enabled,
|
use_skills=skills_enabled,
|
||||||
)
|
)
|
||||||
if use_rag is not None or is_research_spinoff:
|
if use_rag is not None or is_research_spinoff or casual_low_signal:
|
||||||
_preface_kwargs["use_rag"] = use_rag_val
|
_preface_kwargs["use_rag"] = use_rag_val
|
||||||
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
||||||
|
|
||||||
@@ -643,7 +743,7 @@ async def build_chat_context(
|
|||||||
used_memories = getattr(chat_processor, '_last_used_memories', [])
|
used_memories = getattr(chat_processor, '_last_used_memories', [])
|
||||||
|
|
||||||
# Inject pre-fetched search context (compare mode)
|
# Inject pre-fetched search context (compare mode)
|
||||||
if search_context and allow_tool_preprocessing:
|
if search_context and allow_tool_preprocessing and not casual_low_signal:
|
||||||
preface.append(untrusted_context_message("prefetched search context", search_context))
|
preface.append(untrusted_context_message("prefetched search context", search_context))
|
||||||
|
|
||||||
# YouTube transcripts
|
# YouTube transcripts
|
||||||
@@ -687,7 +787,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,
|
||||||
@@ -701,7 +806,13 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+196
-32
@@ -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
|
||||||
@@ -29,6 +30,7 @@ from routes.document_helpers import _owner_session_filter
|
|||||||
from core.database import SessionLocal, get_session_mode, set_session_mode
|
from core.database import SessionLocal, get_session_mode, set_session_mode
|
||||||
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
||||||
from core.database import Document as DBDocument, ModelEndpoint
|
from core.database import Document as DBDocument, ModelEndpoint
|
||||||
|
from core.log_safety import redact_url
|
||||||
from routes.research_routes import _resolve_research_endpoint
|
from routes.research_routes import _resolve_research_endpoint
|
||||||
from routes.model_routes import _visible_models
|
from routes.model_routes import _visible_models
|
||||||
from routes.chat_helpers import (
|
from routes.chat_helpers import (
|
||||||
@@ -39,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__)
|
||||||
|
|
||||||
@@ -62,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).
|
||||||
|
|
||||||
@@ -509,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.
|
||||||
@@ -526,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}")
|
||||||
|
|
||||||
@@ -618,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):
|
||||||
@@ -728,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,
|
||||||
@@ -765,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.
|
||||||
@@ -789,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
|
||||||
@@ -926,7 +1048,7 @@ def setup_chat_routes(
|
|||||||
if effective_do_research:
|
if effective_do_research:
|
||||||
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
||||||
_auth_keys = list(_r_headers.keys()) if _r_headers else []
|
_auth_keys = list(_r_headers.keys()) if _r_headers else []
|
||||||
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={_r_ep}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
|
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={redact_url(_r_ep)}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
|
||||||
|
|
||||||
# Clarification round: only for very short/vague queries on first research message.
|
# Clarification round: only for very short/vague queries on first research message.
|
||||||
# Skip in compare mode — each pane is a fresh session, so every one would
|
# Skip in compare mode — each pane is a fresh session, so every one would
|
||||||
@@ -1043,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
|
||||||
@@ -1139,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
|
||||||
@@ -1159,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
|
||||||
@@ -1201,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,
|
||||||
@@ -1215,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,
|
||||||
@@ -1250,7 +1386,14 @@ def setup_chat_routes(
|
|||||||
try:
|
try:
|
||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
|
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
|
||||||
|
# Per-message tool budget from settings; guard defensively in
|
||||||
|
# case settings.json was hand-edited to a non-numeric value
|
||||||
|
# (the HTTP admin endpoint validates, but direct edits bypass
|
||||||
|
# it). 0 = unlimited, matching auth_routes set_settings().
|
||||||
|
try:
|
||||||
_tool_budget = int(get_setting("agent_max_tool_calls", 0))
|
_tool_budget = int(get_setting("agent_max_tool_calls", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
_tool_budget = 0
|
||||||
# Per-message round cap from settings; clamp defensively in
|
# Per-message round cap from settings; clamp defensively in
|
||||||
# case settings.json was hand-edited to a bad value.
|
# case settings.json was hand-edited to a bad value.
|
||||||
try:
|
try:
|
||||||
@@ -1259,6 +1402,10 @@ def setup_chat_routes(
|
|||||||
_max_rounds = _DEFAULT_ROUNDS
|
_max_rounds = _DEFAULT_ROUNDS
|
||||||
_max_rounds = max(1, min(_max_rounds, 200))
|
_max_rounds = max(1, min(_max_rounds, 200))
|
||||||
|
|
||||||
|
_forced_tools = None
|
||||||
|
if _search_enabled:
|
||||||
|
_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,
|
||||||
sess.model,
|
sess.model,
|
||||||
@@ -1280,6 +1427,8 @@ def setup_chat_routes(
|
|||||||
plan_mode=plan_mode,
|
plan_mode=plan_mode,
|
||||||
approved_plan=approved_plan or None,
|
approved_plan=approved_plan or None,
|
||||||
workspace=workspace or None,
|
workspace=workspace or None,
|
||||||
|
forced_tools=_forced_tools,
|
||||||
|
uploaded_files=ctx.uploaded_files,
|
||||||
):
|
):
|
||||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||||
try:
|
try:
|
||||||
@@ -1288,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
|
||||||
@@ -1299,7 +1450,9 @@ def setup_chat_routes(
|
|||||||
"tool_start", "tool_output", "agent_step",
|
"tool_start", "tool_output", "agent_step",
|
||||||
"doc_stream_open", "doc_stream_delta",
|
"doc_stream_open", "doc_stream_delta",
|
||||||
"doc_update", "doc_suggestions", "ui_control",
|
"doc_update", "doc_suggestions", "ui_control",
|
||||||
"rounds_exhausted",
|
"rounds_exhausted", "budget_exceeded",
|
||||||
|
"loop_breaker_triggered",
|
||||||
|
"intent_nudge_exhausted",
|
||||||
"ask_user",
|
"ask_user",
|
||||||
"plan_update",
|
"plan_update",
|
||||||
):
|
):
|
||||||
@@ -1326,15 +1479,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,
|
||||||
@@ -1344,8 +1508,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,
|
||||||
|
|||||||
+45
-10
@@ -15,6 +15,7 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from core.middleware import require_admin
|
||||||
from src.auth_helpers import require_authenticated_request, require_user
|
from src.auth_helpers import require_authenticated_request, require_user
|
||||||
from src.tool_implementations import do_manage_notes
|
from src.tool_implementations import do_manage_notes
|
||||||
from src.constants import COOKBOOK_STATE_FILE
|
from src.constants import COOKBOOK_STATE_FILE
|
||||||
@@ -109,6 +110,20 @@ def _scope_owner_all(request: Request, required: set[str]) -> str:
|
|||||||
return require_user(request)
|
return require_user(request)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
|
||||||
|
"""Authorize a Codex cookbook route.
|
||||||
|
|
||||||
|
For API-token callers, enforce the given scope set.
|
||||||
|
For cookie-session callers, additionally require admin privileges
|
||||||
|
because cookbook surfaces expose host topology, task logs, tmux
|
||||||
|
commands, and model-serving controls.
|
||||||
|
"""
|
||||||
|
owner = _scope_owner(request, allowed)
|
||||||
|
if not getattr(request.state, "api_token", False):
|
||||||
|
require_admin(request)
|
||||||
|
return owner
|
||||||
|
|
||||||
|
|
||||||
def _find_endpoint(router: APIRouter | None, method: str, path: str):
|
def _find_endpoint(router: APIRouter | None, method: str, path: str):
|
||||||
if router is None:
|
if router is None:
|
||||||
return None
|
return None
|
||||||
@@ -118,6 +133,18 @@ def _find_endpoint(router: APIRouter | None, method: str, path: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_pagination(offset: Any, limit: Any, *, default_limit: int = 50, max_limit: int = 50) -> tuple[int, int]:
|
||||||
|
try:
|
||||||
|
parsed_offset = int(0 if offset in (None, "") else offset)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "Invalid offset")
|
||||||
|
try:
|
||||||
|
parsed_limit = int(default_limit if limit in (None, "") else limit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "Invalid limit")
|
||||||
|
return max(0, parsed_offset), max(1, min(parsed_limit, max_limit))
|
||||||
|
|
||||||
|
|
||||||
def setup_codex_routes(
|
def setup_codex_routes(
|
||||||
email_router: APIRouter | None = None,
|
email_router: APIRouter | None = None,
|
||||||
memory_router: APIRouter | None = None,
|
memory_router: APIRouter | None = None,
|
||||||
@@ -425,10 +452,18 @@ def setup_codex_routes(
|
|||||||
owner = _scope_owner(request, DOCS_READ_SCOPES)
|
owner = _scope_owner(request, DOCS_READ_SCOPES)
|
||||||
if documents_library_endpoint is None:
|
if documents_library_endpoint is None:
|
||||||
raise HTTPException(503, "Documents integration is not available")
|
raise HTTPException(503, "Documents integration is not available")
|
||||||
return await _as_owner(
|
offset, limit = _clamp_pagination(offset, limit)
|
||||||
|
result = await _as_owner(
|
||||||
request, owner, documents_library_endpoint,
|
request, owner, documents_library_endpoint,
|
||||||
request, search, language, sort, offset, limit, archived,
|
request, search, language, sort, offset, limit, archived,
|
||||||
)
|
)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
docs = result.get("documents")
|
||||||
|
total = result.get("total")
|
||||||
|
if isinstance(docs, list) and isinstance(total, int):
|
||||||
|
next_offset = offset + len(docs)
|
||||||
|
result["next_offset"] = next_offset if next_offset < total else None
|
||||||
|
return result
|
||||||
|
|
||||||
@router.get("/documents/{doc_id}")
|
@router.get("/documents/{doc_id}")
|
||||||
async def codex_documents_get(request: Request, doc_id: str):
|
async def codex_documents_get(request: Request, doc_id: str):
|
||||||
@@ -532,14 +567,14 @@ def setup_codex_routes(
|
|||||||
|
|
||||||
@router.get("/cookbook/tasks")
|
@router.get("/cookbook/tasks")
|
||||||
async def codex_cookbook_tasks(request: Request):
|
async def codex_cookbook_tasks(request: Request):
|
||||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||||
state = _read_cookbook_state()
|
state = _read_cookbook_state()
|
||||||
tasks = state.get("tasks") or []
|
tasks = state.get("tasks") or []
|
||||||
return {"tasks": [_redact_task(t) for t in tasks]}
|
return {"tasks": [_redact_task(t) for t in tasks]}
|
||||||
|
|
||||||
@router.get("/cookbook/servers")
|
@router.get("/cookbook/servers")
|
||||||
async def codex_cookbook_servers(request: Request):
|
async def codex_cookbook_servers(request: Request):
|
||||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||||
state = _read_cookbook_state()
|
state = _read_cookbook_state()
|
||||||
servers = state.get("env", {}).get("servers") or []
|
servers = state.get("env", {}).get("servers") or []
|
||||||
# Strip ssh creds / passwords; keep only what's needed to pick a host.
|
# Strip ssh creds / passwords; keep only what's needed to pick a host.
|
||||||
@@ -558,7 +593,7 @@ def setup_codex_routes(
|
|||||||
|
|
||||||
@router.get("/cookbook/output/{session_id}")
|
@router.get("/cookbook/output/{session_id}")
|
||||||
async def codex_cookbook_output(request: Request, session_id: str, tail: int = 400):
|
async def codex_cookbook_output(request: Request, session_id: str, tail: int = 400):
|
||||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||||
# Defensive: session_id must be the tmux-style id we issue
|
# Defensive: session_id must be the tmux-style id we issue
|
||||||
# (`serve-XXXX` / `cookbook-XXXX` / `queue-XXXX`); anything else
|
# (`serve-XXXX` / `cookbook-XXXX` / `queue-XXXX`); anything else
|
||||||
# would let the agent run arbitrary `tmux capture-pane` targets.
|
# would let the agent run arbitrary `tmux capture-pane` targets.
|
||||||
@@ -600,7 +635,7 @@ def setup_codex_routes(
|
|||||||
|
|
||||||
@router.post("/cookbook/serve")
|
@router.post("/cookbook/serve")
|
||||||
async def codex_cookbook_serve(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
async def codex_cookbook_serve(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
|
||||||
# Wraps /api/model/serve with the SAME validation the UI uses.
|
# Wraps /api/model/serve with the SAME validation the UI uses.
|
||||||
# _validate_serve_cmd (called inside model_serve) rejects shell
|
# _validate_serve_cmd (called inside model_serve) rejects shell
|
||||||
# metachars and requires the leading binary to be in the
|
# metachars and requires the leading binary to be in the
|
||||||
@@ -639,7 +674,7 @@ def setup_codex_routes(
|
|||||||
|
|
||||||
@router.post("/cookbook/stop/{session_id}")
|
@router.post("/cookbook/stop/{session_id}")
|
||||||
async def codex_cookbook_stop(request: Request, session_id: str):
|
async def codex_cookbook_stop(request: Request, session_id: str):
|
||||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
|
||||||
import re as _re
|
import re as _re
|
||||||
if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
|
if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
|
||||||
raise HTTPException(400, "Invalid session id")
|
raise HTTPException(400, "Invalid session id")
|
||||||
@@ -659,7 +694,7 @@ def setup_codex_routes(
|
|||||||
"""List cached models on a configured server (or local if host is omitted).
|
"""List cached models on a configured server (or local if host is omitted).
|
||||||
Mirrors `list_cached_models` from the chat agent so external agents have
|
Mirrors `list_cached_models` from the chat agent so external agents have
|
||||||
the same inventory view before deciding what to serve/download."""
|
the same inventory view before deciding what to serve/download."""
|
||||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||||
# Hit /api/model/cached internally, with the same modelDirs the chat
|
# Hit /api/model/cached internally, with the same modelDirs the chat
|
||||||
# agent's list_cached_models would resolve from cookbook state.
|
# agent's list_cached_models would resolve from cookbook state.
|
||||||
state = _read_cookbook_state()
|
state = _read_cookbook_state()
|
||||||
@@ -721,7 +756,7 @@ def setup_codex_routes(
|
|||||||
"""List saved serve presets (model + host + port + launch cmd).
|
"""List saved serve presets (model + host + port + launch cmd).
|
||||||
Counterpart to `list_serve_presets`. Use BEFORE composing a `serve`
|
Counterpart to `list_serve_presets`. Use BEFORE composing a `serve`
|
||||||
body — the user's saved preset usually has the working cmd already."""
|
body — the user's saved preset usually has the working cmd already."""
|
||||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
|
||||||
state = _read_cookbook_state()
|
state = _read_cookbook_state()
|
||||||
presets = state.get("presets") or []
|
presets = state.get("presets") or []
|
||||||
out = []
|
out = []
|
||||||
@@ -741,7 +776,7 @@ def setup_codex_routes(
|
|||||||
async def codex_cookbook_serve_preset(request: Request, name: str):
|
async def codex_cookbook_serve_preset(request: Request, name: str):
|
||||||
"""Launch a saved preset by name. Reuses the working cmd + host the
|
"""Launch a saved preset by name. Reuses the working cmd + host the
|
||||||
user already saved, avoiding the cmd-allowlist trial-and-error loop."""
|
user already saved, avoiding the cmd-allowlist trial-and-error loop."""
|
||||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
|
||||||
import re as _re
|
import re as _re
|
||||||
if not _re.fullmatch(r"[A-Za-z0-9 _.:@\-]+", name):
|
if not _re.fullmatch(r"[A-Za-z0-9 _.:@\-]+", name):
|
||||||
raise HTTPException(400, "Invalid preset name")
|
raise HTTPException(400, "Invalid preset name")
|
||||||
@@ -793,7 +828,7 @@ def setup_codex_routes(
|
|||||||
cookbook tracking. Needed when serve_model rejects a cmd and the
|
cookbook tracking. Needed when serve_model rejects a cmd and the
|
||||||
agent falls back to direct ssh — without adoption the session is
|
agent falls back to direct ssh — without adoption the session is
|
||||||
invisible to the UI. Body: {tmux_session, model, host?, port?}."""
|
invisible to the UI. Body: {tmux_session, model, host?, port?}."""
|
||||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
|
||||||
norm = dict(body or {})
|
norm = dict(body or {})
|
||||||
sess = (norm.get("tmux_session") or norm.get("session_id") or "").strip()
|
sess = (norm.get("tmux_session") or norm.get("session_id") or "").strip()
|
||||||
model = (norm.get("model") or norm.get("repo_id") or "").strip()
|
model = (norm.get("model") or norm.get("repo_id") or "").strip()
|
||||||
|
|||||||
@@ -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
-877
@@ -1,882 +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 fastapi import APIRouter, Query, Depends, Response, HTTPException
|
from routes.contacts import contacts_routes as _canonical # noqa: F401
|
||||||
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."""
|
|
||||||
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):
|
|
||||||
_contact_cache["fetched_at"] = None
|
|
||||||
return True
|
|
||||||
if r.status_code == 404:
|
|
||||||
# Resource not found at the resolved URL. With href resolution
|
|
||||||
# this should be rare (genuinely already deleted). Invalidate
|
|
||||||
# the cache and report success so the UI doesn't keep a ghost.
|
|
||||||
logger.info(f"CardDAV DELETE 404 for {uid} — treating as already gone")
|
|
||||||
_contact_cache["fetched_at"] = None
|
|
||||||
return True
|
|
||||||
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
|
|
||||||
|
|||||||
+267
-35
@@ -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,10 +584,22 @@ 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 = {
|
||||||
"vllm", "llama-server", "llama_server", "llama.cpp", "ollama",
|
"vllm", "llama-server", "llama-server.exe", "llama_server", "llama.cpp", "ollama",
|
||||||
"python", "python3",
|
"python", "python3",
|
||||||
"sglang", "lmdeploy",
|
"sglang", "lmdeploy",
|
||||||
"node", "npx",
|
"node", "npx",
|
||||||
@@ -577,6 +615,16 @@ _SERVE_CMD_ALLOWLIST = {
|
|||||||
_GGUF_PRELUDE_RE = re.compile(
|
_GGUF_PRELUDE_RE = re.compile(
|
||||||
r'^MODEL_FILE=\$\([^\n]*?\)\s*&&\s*\{[^{}]*\}\s*\|\|\s*\{[^{}]*\}\s*&&\s*'
|
r'^MODEL_FILE=\$\([^\n]*?\)\s*&&\s*\{[^{}]*\}\s*\|\|\s*\{[^{}]*\}\s*&&\s*'
|
||||||
)
|
)
|
||||||
|
_SAFE_SUBSHELL_TEXT = r"[^'\n;&|`$()<>]+"
|
||||||
|
_SAFE_SUBSHELL_DQ_HOME_PATH = r'"\$HOME/[^"\n;&|`()<>]*"'
|
||||||
|
_SAFE_PRINTF_SUBSHELL_RE = re.compile(
|
||||||
|
rf"^\$\(printf[ \t]+%s[ \t]+(?:'{_SAFE_SUBSHELL_TEXT}'|\$\{{HOME\}}'/{_SAFE_SUBSHELL_TEXT}')\)$"
|
||||||
|
)
|
||||||
|
_SAFE_FIND_MMPROJ_SUBSHELL_RE = re.compile(
|
||||||
|
rf"^\$\(find[ \t]+(?:'{_SAFE_SUBSHELL_TEXT}'|{_SAFE_SUBSHELL_DQ_HOME_PATH}|{_SAFE_SUBSHELL_TEXT})"
|
||||||
|
r"[ \t]+-iname[ \t]+'mmproj\*\.gguf'"
|
||||||
|
r"(?:[ \t]+2>/dev/null)?[ \t]*\|[ \t]*sort[ \t]*\|[ \t]*head[ \t]+-1\)$"
|
||||||
|
)
|
||||||
_OLLAMA_HOST_ASSIGNMENT_RE = re.compile(r"(?:^|\s)OLLAMA_HOST=([^\s]+)")
|
_OLLAMA_HOST_ASSIGNMENT_RE = re.compile(r"(?:^|\s)OLLAMA_HOST=([^\s]+)")
|
||||||
_OLLAMA_BIND_RE = re.compile(r"^\[([^\]]+)\]:(\d+)$|^([^:]+):(\d+)$")
|
_OLLAMA_BIND_RE = re.compile(r"^\[([^\]]+)\]:(\d+)$|^([^:]+):(\d+)$")
|
||||||
_OLLAMA_BIND_HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
|
_OLLAMA_BIND_HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
|
||||||
@@ -677,6 +725,13 @@ def _check_serve_binary(seg: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_serve_subshell(subshell: str) -> bool:
|
||||||
|
return bool(
|
||||||
|
_SAFE_PRINTF_SUBSHELL_RE.fullmatch(subshell)
|
||||||
|
or _SAFE_FIND_MMPROJ_SUBSHELL_RE.fullmatch(subshell)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_serve_cmd(v: str | None) -> str | None:
|
def _validate_serve_cmd(v: str | None) -> str | None:
|
||||||
"""Reject serve commands that aren't in the allowlist or contain shell metachars.
|
"""Reject serve commands that aren't in the allowlist or contain shell metachars.
|
||||||
|
|
||||||
@@ -708,15 +763,15 @@ def _validate_serve_cmd(v: str | None) -> str | None:
|
|||||||
_check_serve_binary(part.strip())
|
_check_serve_binary(part.strip())
|
||||||
return v
|
return v
|
||||||
|
|
||||||
# Otherwise: a single invocation — no shell metacharacters allowed.
|
# Otherwise: a single invocation — no shell metacharacters allowed. Replace
|
||||||
# Temporarily replace safe $(printf %s ...) expressions with a placeholder
|
# only the exact command substitutions emitted by the Cookbook UI:
|
||||||
# to avoid triggering the metacharacter/command-injection checks.
|
# $(printf %s 'safe-path') and the mmproj lookup
|
||||||
cleaned_v = v
|
# $(find <path> -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1).
|
||||||
printf_matches = list(re.finditer(r"\$\(\s*printf\s+%s\s+([^\n()]*?)\)", v))
|
def _replace_safe_subshell(match: re.Match[str]) -> str:
|
||||||
for match in printf_matches:
|
subshell = match.group(0)
|
||||||
inner = match.group(1)
|
return "/placeholder/safe/path" if _is_safe_serve_subshell(subshell) else subshell
|
||||||
if not any(c in inner for c in (";", "&&", "||", "$(", "`")):
|
|
||||||
cleaned_v = cleaned_v.replace(match.group(0), "/placeholder/safe/path.gguf")
|
cleaned_v = re.sub(r"\$\([^()]*\)", _replace_safe_subshell, v)
|
||||||
|
|
||||||
# (`$(` was the original intent; bare `$` is fine for shell-safe paths.)
|
# (`$(` was the original intent; bare `$` is fine for shell-safe paths.)
|
||||||
if any(c in cleaned_v for c in (";", "&&", "||", "$(")):
|
if any(c in cleaned_v for c in (";", "&&", "||", "$(")):
|
||||||
@@ -786,25 +841,149 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
|
|||||||
to hard-wire CUDA on Linux. That made ROCm hosts attempt a CUDA configure and
|
to hard-wire CUDA on Linux. That made ROCm hosts attempt a CUDA configure and
|
||||||
fail with "CUDA Toolkit not found" instead of building with HIP.
|
fail with "CUDA Toolkit not found" instead of building with HIP.
|
||||||
"""
|
"""
|
||||||
|
# Try a prebuilt binary from llama.cpp's GitHub releases FIRST — no
|
||||||
|
# cmake/build-essential/git/CUDA-headers needed at all. The from-source
|
||||||
|
# build below stays as a fallback (custom flags, esoteric arch, no
|
||||||
|
# internet, etc). 30 seconds vs 5+ minutes of compile, and removes
|
||||||
|
# every OS-package dep from the launch path. Sets _odysseus_have_prebuilt=1
|
||||||
|
# on success; the existing build-tier if/elif chain below is gated on
|
||||||
|
# that variable so we never compile twice or shadow the prebuilt symlink.
|
||||||
|
runner_lines.append(' _odysseus_have_prebuilt=""')
|
||||||
|
runner_lines.append(' _odysseus_arch="$(uname -m)"')
|
||||||
|
runner_lines.append(' _odysseus_prebuilt_url=""')
|
||||||
|
runner_lines.append(' if command -v curl >/dev/null 2>&1 && [ "$_odysseus_arch" = "x86_64" ]; then')
|
||||||
|
runner_lines.append(' _odysseus_pat=""')
|
||||||
|
runner_lines.append(' _odysseus_has_nv_inline() { command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L 2>/dev/null | grep -q "GPU "; }')
|
||||||
|
runner_lines.append(' _odysseus_has_vk_inline() { ldconfig -p 2>/dev/null | grep -q "libvulkan\\.so" || command -v vulkaninfo >/dev/null 2>&1 || [ -e /usr/lib/x86_64-linux-gnu/libvulkan.so.1 ]; }')
|
||||||
|
runner_lines.append(' _odysseus_has_vkdev_inline() { ls /dev/dri/renderD* >/dev/null 2>&1 || (lspci 2>/dev/null | grep -Ei \'VGA|3D|Display\' | grep -Eiq \'AMD|ATI|Radeon\'); }')
|
||||||
|
runner_lines.append(' if _odysseus_has_nv_inline; then')
|
||||||
|
runner_lines.append(' _odysseus_pat="ubuntu.*cuda"')
|
||||||
|
runner_lines.append(' elif _odysseus_has_vkdev_inline && _odysseus_has_vk_inline; then')
|
||||||
|
runner_lines.append(' _odysseus_pat="ubuntu.*vulkan"')
|
||||||
|
runner_lines.append(' else')
|
||||||
|
runner_lines.append(' _odysseus_pat="ubuntu-x64\\\\.zip"')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' _odysseus_prebuilt_url="$(curl -fsSL --max-time 15 https://api.github.com/repos/ggml-org/llama.cpp/releases/latest 2>/dev/null | grep \'"browser_download_url"\' | cut -d\'"\' -f4 | grep -iE "$_odysseus_pat" | grep -iv "arm\\|aarch64" | head -1)"')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
# Accept any of unzip / bsdtar / python3 -m zipfile as the extractor.
|
||||||
|
# python3 is essentially always present on modern Linux, so this lets
|
||||||
|
# the prebuilt path work on minimal Ubuntu installs that lack `unzip`.
|
||||||
|
runner_lines.append(' if [ -n "$_odysseus_prebuilt_url" ] && (command -v unzip >/dev/null 2>&1 || command -v bsdtar >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1); then')
|
||||||
|
runner_lines.append(' echo "[odysseus] Found prebuilt llama-server: $_odysseus_prebuilt_url"')
|
||||||
|
runner_lines.append(' mkdir -p ~/bin "$HOME/.cache/odysseus/llama-cpp-prebuilt" && cd "$HOME/.cache/odysseus/llama-cpp-prebuilt"')
|
||||||
|
runner_lines.append(' rm -f llama-cpp.zip')
|
||||||
|
runner_lines.append(' if curl -fsSL --max-time 120 "$_odysseus_prebuilt_url" -o llama-cpp.zip && [ -s llama-cpp.zip ]; then')
|
||||||
|
runner_lines.append(' rm -rf build && mkdir -p build')
|
||||||
|
runner_lines.append(' if command -v unzip >/dev/null 2>&1; then unzip -qq -o llama-cpp.zip -d build; elif command -v bsdtar >/dev/null 2>&1; then bsdtar -xf llama-cpp.zip -C build; else python3 -c "import zipfile; zipfile.ZipFile(\\"llama-cpp.zip\\").extractall(\\"build\\")"; fi')
|
||||||
|
runner_lines.append(' _odysseus_extracted="$(find build -type f -name llama-server 2>/dev/null | head -1)"')
|
||||||
|
runner_lines.append(' if [ -n "$_odysseus_extracted" ]; then')
|
||||||
|
runner_lines.append(' chmod +x "$_odysseus_extracted"')
|
||||||
|
runner_lines.append(' ln -sf "$_odysseus_extracted" ~/bin/llama-server')
|
||||||
|
runner_lines.append(' _odysseus_libdir="$(dirname "$_odysseus_extracted")"')
|
||||||
|
runner_lines.append(' mkdir -p ~/.config && echo "export LD_LIBRARY_PATH=\\"$_odysseus_libdir:\\${LD_LIBRARY_PATH:-}\\"" > ~/.config/odysseus-llama-cpp-env')
|
||||||
|
runner_lines.append(' _odysseus_have_prebuilt=1')
|
||||||
|
runner_lines.append(' echo "[odysseus] Prebuilt llama-server installed at $_odysseus_extracted"')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' [ -z "$_odysseus_have_prebuilt" ] && echo "[odysseus] Prebuilt download/extract failed — falling back to from-source build."')
|
||||||
|
runner_lines.append(' elif [ -z "$_odysseus_prebuilt_url" ]; then')
|
||||||
|
runner_lines.append(' echo "[odysseus] No matching prebuilt llama-server for this host (arch=$_odysseus_arch) — will build from source."')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' if [ -z "$_odysseus_have_prebuilt" ]; then')
|
||||||
# Detect pip-installed nvcc (from vLLM/nvidia CUDA wheels) and put it on PATH
|
# Detect pip-installed nvcc (from vLLM/nvidia CUDA wheels) and put it on PATH
|
||||||
# so cmake's CUDA configure can find it. We keep this after the ROCm/HIP
|
# so cmake's CUDA configure can find it — BUT only when actual NVIDIA
|
||||||
# check — a machine with both stacks should honor the native HIP toolchain on
|
# hardware is present. On AMD/Intel hosts the pip nvcc is a misleading
|
||||||
# AMD hosts instead of accidentally preferring a stray nvcc wheel.
|
# leftover (no libcudart, no GPU it could target) and would otherwise
|
||||||
|
# send the build down the CUDA branch and fail with "CUDA Toolkit not
|
||||||
|
# found" instead of trying Vulkan.
|
||||||
|
runner_lines.append(' _odysseus_has_nvidia_hw() {')
|
||||||
|
runner_lines.append(' command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L 2>/dev/null | grep -q "GPU " && return 0')
|
||||||
|
runner_lines.append(' ls /dev/nvidia* >/dev/null 2>&1 && return 0')
|
||||||
|
runner_lines.append(' lspci 2>/dev/null | grep -iE \'VGA|3D|Display\' | grep -iq nvidia && return 0')
|
||||||
|
runner_lines.append(' return 1')
|
||||||
|
runner_lines.append(' }')
|
||||||
|
runner_lines.append(' if _odysseus_has_nvidia_hw; then')
|
||||||
runner_lines.append(' for _cudir in ~/.local/lib/python*/site-packages/nvidia/cu13 ~/.local/lib/python*/site-packages/nvidia/cu12 ~/.local/lib/python*/site-packages/nvidia/cuda_nvcc; do')
|
runner_lines.append(' for _cudir in ~/.local/lib/python*/site-packages/nvidia/cu13 ~/.local/lib/python*/site-packages/nvidia/cu12 ~/.local/lib/python*/site-packages/nvidia/cuda_nvcc; do')
|
||||||
runner_lines.append(' [ -x "$_cudir/bin/nvcc" ] && export CUDA_HOME="$_cudir" && export PATH="$_cudir/bin:$PATH" && break')
|
runner_lines.append(' [ -x "$_cudir/bin/nvcc" ] && export CUDA_HOME="$_cudir" && export PATH="$_cudir/bin:$PATH" && break')
|
||||||
runner_lines.append(' done')
|
runner_lines.append(' done')
|
||||||
|
runner_lines.append(' fi')
|
||||||
# rm -rf build so a prior poisoned CMakeCache.txt (e.g. from a failed CUDA
|
# rm -rf build so a prior poisoned CMakeCache.txt (e.g. from a failed CUDA
|
||||||
# or HIP attempt) doesn't cause the next configure to reuse stale settings.
|
# or HIP attempt) doesn't cause the next configure to reuse stale settings.
|
||||||
runner_lines.append(' mkdir -p ~/bin')
|
runner_lines.append(' mkdir -p ~/bin')
|
||||||
runner_lines.append(' cd ~/llama.cpp && rm -rf build')
|
# Try to install cmake / build-essential / git automatically before the
|
||||||
|
# build, but ONLY via passwordless sudo (`sudo -n`) — interactive sudo
|
||||||
|
# would hang a tmux-backgrounded serve task waiting for a password. If
|
||||||
|
# sudo asks for a password the install is skipped silently and the
|
||||||
|
# diagnosis pattern (cookbook_routes.py / cookbook_helpers.py) surfaces
|
||||||
|
# an explicit "install cmake" suggestion in the Cookbook diagnosis
|
||||||
|
# toolbar after the inevitable build failure.
|
||||||
|
runner_lines.append(' _odysseus_apt_bootstrap() {')
|
||||||
|
runner_lines.append(' local _missing=""')
|
||||||
|
runner_lines.append(' command -v cmake >/dev/null 2>&1 || _missing="$_missing cmake"')
|
||||||
|
runner_lines.append(' command -v g++ >/dev/null 2>&1 || command -v gcc >/dev/null 2>&1 || _missing="$_missing build-essential"')
|
||||||
|
runner_lines.append(' command -v git >/dev/null 2>&1 || _missing="$_missing git"')
|
||||||
|
runner_lines.append(' [ -z "$_missing" ] && return 0')
|
||||||
|
runner_lines.append(' if command -v apt-get >/dev/null 2>&1 && sudo -n true 2>/dev/null; then')
|
||||||
|
runner_lines.append(' echo "[odysseus] Auto-installing missing build deps via apt:$_missing"')
|
||||||
|
runner_lines.append(' sudo -n env DEBIAN_FRONTEND=noninteractive apt-get update -qq 2>&1 | tail -3')
|
||||||
|
runner_lines.append(' sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $_missing 2>&1 | tail -5 || true')
|
||||||
|
runner_lines.append(' elif command -v pacman >/dev/null 2>&1 && sudo -n true 2>/dev/null; then')
|
||||||
|
runner_lines.append(' echo "[odysseus] Auto-installing missing build deps via pacman:$_missing"')
|
||||||
|
runner_lines.append(' local _pacpkgs="$(echo "$_missing" | sed -e \'s/build-essential/base-devel/g\')"')
|
||||||
|
runner_lines.append(' sudo -n pacman -Sy --needed --noconfirm $_pacpkgs 2>&1 | tail -5 || true')
|
||||||
|
runner_lines.append(' elif command -v dnf >/dev/null 2>&1 && sudo -n true 2>/dev/null; then')
|
||||||
|
runner_lines.append(' echo "[odysseus] Auto-installing missing build deps via dnf:$_missing"')
|
||||||
|
runner_lines.append(' local _dnfpkgs="$(echo "$_missing" | sed -e \'s/build-essential/gcc gcc-c++ make/g\')"')
|
||||||
|
runner_lines.append(' sudo -n dnf install -y $_dnfpkgs 2>&1 | tail -5 || true')
|
||||||
|
runner_lines.append(' else')
|
||||||
|
runner_lines.append(' echo "[odysseus] WARNING: missing build deps ($_missing) — passwordless sudo is unavailable, cannot auto-install. Cookbook Diagnosis will explain the fix after the build fails."')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' }')
|
||||||
|
runner_lines.append(' _odysseus_apt_bootstrap')
|
||||||
|
runner_lines.append(' _odysseus_missing_build_deps=""')
|
||||||
|
runner_lines.append(' command -v cmake >/dev/null 2>&1 || _odysseus_missing_build_deps="$_odysseus_missing_build_deps cmake"')
|
||||||
|
runner_lines.append(' command -v git >/dev/null 2>&1 || _odysseus_missing_build_deps="$_odysseus_missing_build_deps git"')
|
||||||
|
runner_lines.append(' command -v g++ >/dev/null 2>&1 || command -v gcc >/dev/null 2>&1 || _odysseus_missing_build_deps="$_odysseus_missing_build_deps build-essential"')
|
||||||
|
runner_lines.append(' if [ -n "$_odysseus_missing_build_deps" ]; then')
|
||||||
|
runner_lines.append(' echo "ERROR: llama.cpp source build needs missing packages:$_odysseus_missing_build_deps"')
|
||||||
|
runner_lines.append(' if command -v apt-get >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' echo "Install on this host: sudo apt-get update && sudo apt-get install -y cmake build-essential git"')
|
||||||
|
runner_lines.append(' elif command -v pacman >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' echo "Install on this host: sudo pacman -Sy --needed cmake base-devel git"')
|
||||||
|
runner_lines.append(' elif command -v dnf >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' echo "Install on this host: sudo dnf install -y cmake gcc gcc-c++ make git"')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' echo "Alternative: install a native llama-server on PATH, then relaunch."')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' cd ~/llama.cpp')
|
||||||
|
runner_lines.append(' _odysseus_has_vulkan() {')
|
||||||
|
runner_lines.append(' ldconfig -p 2>/dev/null | grep -q \'libvulkan\\.so\' && return 0')
|
||||||
|
runner_lines.append(' [ -e /usr/lib/libvulkan.so.1 ] && return 0')
|
||||||
|
runner_lines.append(' [ -e /usr/lib/x86_64-linux-gnu/libvulkan.so.1 ] && return 0')
|
||||||
|
runner_lines.append(' command -v vulkaninfo >/dev/null 2>&1 && return 0')
|
||||||
|
runner_lines.append(' return 1')
|
||||||
|
runner_lines.append(' }')
|
||||||
|
runner_lines.append(' _odysseus_has_vulkan_device() {')
|
||||||
|
runner_lines.append(' ls /dev/dri/renderD* >/dev/null 2>&1 && return 0')
|
||||||
|
runner_lines.append(' lspci 2>/dev/null | grep -Ei \'VGA|3D|Display\' | grep -Eiq \'AMD|ATI|Radeon\' && return 0')
|
||||||
|
runner_lines.append(' return 1')
|
||||||
|
runner_lines.append(' }')
|
||||||
|
# Backend preference: native ROCm/HIP > native CUDA > Vulkan > CPU.
|
||||||
|
# Vulkan is a portable fallback that works on AMD when ROCm isn't
|
||||||
|
# installed (e.g. Strix Halo) and on any vendor's discrete GPU, but
|
||||||
|
# it's ~30-40% slower than native HIP/CUDA for LLM inference — only
|
||||||
|
# pick it when no native toolchain is present.
|
||||||
runner_lines.append(' if command -v hipconfig &>/dev/null || [ -d /opt/rocm ] || [ -n "$ROCM_PATH" ] || [ -n "$HIP_PATH" ]; then')
|
runner_lines.append(' if command -v hipconfig &>/dev/null || [ -d /opt/rocm ] || [ -n "$ROCM_PATH" ] || [ -n "$HIP_PATH" ]; then')
|
||||||
|
runner_lines.append(' rm -rf build')
|
||||||
runner_lines.append(' if command -v hipconfig &>/dev/null; then')
|
runner_lines.append(' if command -v hipconfig &>/dev/null; then')
|
||||||
runner_lines.append(' export HIPCXX="${HIPCXX:-$(hipconfig -l)/clang}"')
|
runner_lines.append(' export HIPCXX="${HIPCXX:-$(hipconfig -l)/clang}"')
|
||||||
runner_lines.append(' export HIP_PATH="${HIP_PATH:-$(hipconfig -R)}"')
|
runner_lines.append(' export HIP_PATH="${HIP_PATH:-$(hipconfig -R)}"')
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
runner_lines.append(' echo "[odysseus] ROCm/HIP detected — building llama-server with HIP support..."')
|
runner_lines.append(' echo "[odysseus] ROCm/HIP detected — building llama-server with HIP support..."')
|
||||||
runner_lines.append(' cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_HIP=ON && cmake --build build -j"$NPROC" --target llama-server && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
runner_lines.append(' cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_HIP=ON && cmake --build build -j"$NPROC" --target llama-server && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
||||||
runner_lines.append(' elif command -v nvcc &>/dev/null; then')
|
runner_lines.append(' elif command -v nvcc &>/dev/null && _odysseus_has_nvidia_hw; then')
|
||||||
|
runner_lines.append(' rm -rf build')
|
||||||
# nvcc alone is not sufficient — pip-installed CUDA wheels or incomplete
|
# nvcc alone is not sufficient — pip-installed CUDA wheels or incomplete
|
||||||
# tooling can expose nvcc without shipping libcudart, causing cmake to fail
|
# tooling can expose nvcc without shipping libcudart, causing cmake to fail
|
||||||
# mid-build with "CUDA runtime library not found". Check cudart explicitly
|
# mid-build with "CUDA runtime library not found". Check cudart explicitly
|
||||||
@@ -828,31 +1007,50 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
|
|||||||
runner_lines.append(' echo "[odysseus] Ensure libcudart is installed (e.g. cuda-runtime package) and visible via ldconfig or CUDA_HOME."')
|
runner_lines.append(' echo "[odysseus] Ensure libcudart is installed (e.g. cuda-runtime package) and visible via ldconfig or CUDA_HOME."')
|
||||||
runner_lines.append(' cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$NPROC" --target llama-server && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
runner_lines.append(' cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$NPROC" --target llama-server && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' elif _odysseus_has_vulkan_device && _odysseus_has_vulkan; then')
|
||||||
|
runner_lines.append(' echo "[odysseus] Vulkan-capable GPU detected (no ROCm/CUDA toolchain installed) — building llama-server with Vulkan support..."')
|
||||||
|
runner_lines.append(' rm -rf build-vulkan')
|
||||||
|
runner_lines.append(' cmake -B build-vulkan -DCMAKE_BUILD_TYPE=Release -DGGML_VULKAN=ON && cmake --build build-vulkan -j"$NPROC" --target llama-server && ln -sf ~/llama.cpp/build-vulkan/bin/llama-server ~/bin/llama-server')
|
||||||
runner_lines.append(' else')
|
runner_lines.append(' else')
|
||||||
runner_lines.append(' echo "[odysseus] WARNING: no HIP/CUDA toolchain found — building llama-server for CPU only."')
|
runner_lines.append(' echo "[odysseus] WARNING: no HIP/CUDA/Vulkan toolchain found — building llama-server for CPU only."')
|
||||||
runner_lines.append(' echo "[odysseus] GPU inference will not be available for this llama.cpp build."')
|
runner_lines.append(' echo "[odysseus] GPU inference will not be available for this llama.cpp build."')
|
||||||
runner_lines.append(' echo "[odysseus] Install ROCm for AMD GPUs or vLLM/CUDA tooling for NVIDIA, then re-launch this serve task."')
|
runner_lines.append(' echo "[odysseus] Install Vulkan (libvulkan-dev) / ROCm for AMD GPUs or CUDA tooling for NVIDIA, then re-launch this serve task."')
|
||||||
|
runner_lines.append(' rm -rf build')
|
||||||
runner_lines.append(' cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$NPROC" --target llama-server && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
runner_lines.append(' cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$NPROC" --target llama-server && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
|
||||||
|
|
||||||
|
|
||||||
def _llama_cpp_rebuild_cmd() -> str:
|
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
|
||||||
"""Shell command that clears the Cookbook-managed llama.cpp build.
|
"""Shell command that clears the Cookbook-managed llama.cpp build.
|
||||||
|
|
||||||
Removes the cached ``llama-server`` symlink and the ``~/llama.cpp/build``
|
Removes the cached ``llama-server`` symlink and the ``~/llama.cpp/build*``
|
||||||
directory so the next llama.cpp serve recompiles from source, picking up a
|
directory so the next llama.cpp serve recompiles from source, picking up a
|
||||||
CUDA or HIP toolchain if one is now available. The serve bootstrap only
|
CUDA or HIP toolchain if one is now available. The serve bootstrap only
|
||||||
builds when ``llama-server`` is missing from PATH, so without this an
|
builds when ``llama-server`` is missing from PATH, so without this an
|
||||||
existing CPU-only build is reused forever. It deliberately installs and
|
existing CPU-only build is reused forever. When ``update_source`` is true,
|
||||||
downloads nothing; the rebuild itself happens on the next serve.
|
the command also fast-forwards the Cookbook-managed ``~/llama.cpp`` checkout
|
||||||
|
if it exists. The rebuild itself happens on the next serve.
|
||||||
"""
|
"""
|
||||||
|
update_cmd = ''
|
||||||
|
if update_source:
|
||||||
|
update_cmd = (
|
||||||
|
'if [ -d "$HOME/llama.cpp/.git" ]; then '
|
||||||
|
'git -C "$HOME/llama.cpp" pull --ff-only --depth 1 || '
|
||||||
|
'echo "[odysseus] WARNING: llama.cpp source update failed; clearing cached build anyway."; '
|
||||||
|
'elif command -v git >/dev/null 2>&1; then '
|
||||||
|
'git clone --depth 1 https://github.com/ggml-org/llama.cpp "$HOME/llama.cpp" || '
|
||||||
|
'echo "[odysseus] WARNING: llama.cpp clone failed; clearing cached build anyway."; '
|
||||||
|
'fi && '
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
'mkdir -p "$HOME/bin" && '
|
'mkdir -p "$HOME/bin" && '
|
||||||
|
f'{update_cmd}'
|
||||||
'rm -f "$HOME/bin/llama-server" && '
|
'rm -f "$HOME/bin/llama-server" && '
|
||||||
'rm -rf "$HOME/llama.cpp/build" && '
|
'rm -rf "$HOME/llama.cpp/build" "$HOME/llama.cpp/build-vulkan" && '
|
||||||
'echo "[odysseus] Cleared the cached llama.cpp build. '
|
'echo "[odysseus] Cleared the cached llama.cpp build. '
|
||||||
'Re-launch the serve task to rebuild llama-server from source '
|
'Re-launch the serve task to rebuild llama-server from source '
|
||||||
'(CUDA or HIP will be used if a toolchain is now available)."'
|
'(Vulkan, HIP, or CUDA will be used if a matching toolchain is now available)."'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1101,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"},
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1116,7 +1316,39 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
|||||||
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'|git: command not found|cmake: command not found",
|
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
|
||||||
|
"MLX LM is not installed on this server.",
|
||||||
|
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||||
|
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||||
|
[
|
||||||
|
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
|
||||||
|
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# System build deps come BEFORE the generic llama.cpp catch-all so
|
||||||
|
# cmake / build-essential / git missing → a specific OS-package
|
||||||
|
# remediation instead of "install llama-cpp-python[server]" (which
|
||||||
|
# itself fails to compile when cmake is absent).
|
||||||
|
(
|
||||||
|
r"cmake: command not found|cmake.*not found.*[Cc]ould not",
|
||||||
|
"cmake is required to build llama.cpp from source but isn't installed on this server.",
|
||||||
|
[{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler",
|
||||||
|
"A C/C++ compiler (build-essential) is required to build llama.cpp from source.",
|
||||||
|
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"^git: command not found",
|
||||||
|
"git is required to clone the llama.cpp source tree.",
|
||||||
|
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'",
|
||||||
"llama.cpp / llama-cpp-python dependencies are missing.",
|
"llama.cpp / llama-cpp-python dependencies are missing.",
|
||||||
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||||
),
|
),
|
||||||
|
|||||||
+1354
-137
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from core.database import Document, DocumentVersion
|
from core.database import Document, DocumentVersion
|
||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
|
from src.auth_helpers import _auth_disabled
|
||||||
from src.upload_handler import UploadHandler
|
from src.upload_handler import UploadHandler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -28,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
|
||||||
@@ -78,6 +80,8 @@ def _verify_doc_owner(db, doc: Document, user: str):
|
|||||||
the session join for any not-yet-backfilled legacy row.
|
the session join for any not-yet-backfilled legacy row.
|
||||||
"""
|
"""
|
||||||
if user is None:
|
if user is None:
|
||||||
|
if _auth_disabled():
|
||||||
|
return # Single-user / no-auth mode: allow access
|
||||||
raise HTTPException(403, "Authentication required")
|
raise HTTPException(403, "Authentication required")
|
||||||
if doc.owner is not None:
|
if doc.owner is not None:
|
||||||
if doc.owner != user:
|
if doc.owner != user:
|
||||||
@@ -104,7 +108,6 @@ def _owner_session_filter(q, user):
|
|||||||
by the time this filter is live there are no NULL-owner rows to leak;
|
by the time this filter is live there are no NULL-owner rows to leak;
|
||||||
we therefore match the owner strictly for authenticated callers."""
|
we therefore match the owner strictly for authenticated callers."""
|
||||||
if not user:
|
if not user:
|
||||||
from src.auth_helpers import _auth_disabled
|
|
||||||
if user == "" or _auth_disabled():
|
if user == "" or _auth_disabled():
|
||||||
return q
|
return q
|
||||||
return q.filter(False)
|
return q.filter(False)
|
||||||
|
|||||||
+103
-17
@@ -10,8 +10,9 @@ from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File,
|
|||||||
from sqlalchemy import case, func, or_
|
from sqlalchemy import case, func, or_
|
||||||
from core.database import SessionLocal, Document, DocumentVersion
|
from core.database import SessionLocal, Document, DocumentVersion
|
||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user, _auth_disabled
|
||||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||||
|
from src.upload_handler import reserve_upload_references
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -54,6 +55,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,
|
||||||
@@ -66,6 +79,14 @@ from routes.document_helpers import (
|
|||||||
def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||||
router = APIRouter(tags=["documents"])
|
router = APIRouter(tags=["documents"])
|
||||||
|
|
||||||
|
def _reserve_document_uploads(user: Optional[str], content: str) -> None:
|
||||||
|
missing_id = reserve_upload_references(upload_handler, user, content)
|
||||||
|
if missing_id:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
)
|
||||||
|
|
||||||
def _locate_current_user_upload(request: Request, upload_id: str, user: Optional[str]):
|
def _locate_current_user_upload(request: Request, upload_id: str, user: Optional[str]):
|
||||||
if upload_handler is None:
|
if upload_handler is None:
|
||||||
return None
|
return None
|
||||||
@@ -99,24 +120,63 @@ 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"
|
||||||
|
|
||||||
|
_reserve_document_uploads(user, req.content)
|
||||||
_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,
|
||||||
@@ -388,6 +448,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
if not user:
|
if not user:
|
||||||
|
if not _auth_disabled():
|
||||||
raise HTTPException(403, "Authentication required")
|
raise HTTPException(403, "Authentication required")
|
||||||
# v2 review HIGH-9: raise 403 explicitly when the caller
|
# v2 review HIGH-9: raise 403 explicitly when the caller
|
||||||
# can't see this session, instead of returning [] which the
|
# can't see this session, instead of returning [] which the
|
||||||
@@ -569,11 +630,24 @@ 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)
|
_reserve_document_uploads(user, incoming_content)
|
||||||
|
_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(
|
||||||
@@ -582,14 +656,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
|
||||||
@@ -601,14 +675,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)
|
||||||
@@ -798,10 +872,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)
|
||||||
@@ -836,10 +926,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":
|
||||||
|
|||||||
+228
-27
@@ -40,6 +40,16 @@ from src.secret_storage import decrypt as _decrypt
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotConfiguredError(RuntimeError):
|
||||||
|
"""Raised when an IMAP operation is attempted on an account that has no
|
||||||
|
inbox configured (e.g. a send-only / SMTP-only account).
|
||||||
|
|
||||||
|
Subclasses RuntimeError so existing broad ``except Exception`` handlers
|
||||||
|
keep working; callers that want to treat "no inbox" as an empty result
|
||||||
|
rather than a failure can catch this type specifically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _xoauth2_raw(user: str, access_token: str) -> str:
|
def _xoauth2_raw(user: str, access_token: str) -> str:
|
||||||
"""The SASL XOAUTH2 initial-response string (unencoded).
|
"""The SASL XOAUTH2 initial-response string (unencoded).
|
||||||
|
|
||||||
@@ -225,8 +235,9 @@ def _strip_think(text: str) -> str:
|
|||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
from src.text_helpers import strip_think as _central, _THINK_CLOSED_RE, _THINK_OPEN_RE, _THINK_TAG_RE
|
from src.text_helpers import strip_think as _central, _THINK_TAG_RE
|
||||||
had_think = bool(_THINK_CLOSED_RE.search(text) or _THINK_OPEN_RE.search(text) or _THINK_TAG_RE.search(text))
|
# Single linear tag check; the old closed/open `.search()` calls could ReDoS.
|
||||||
|
had_think = bool(_THINK_TAG_RE.search(text))
|
||||||
return _central(text, prose=had_think, prompt_echo=True)
|
return _central(text, prose=had_think, prompt_echo=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -338,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:
|
||||||
@@ -351,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
|
||||||
@@ -413,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:
|
||||||
@@ -426,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")
|
||||||
@@ -566,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
|
||||||
@@ -575,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,
|
||||||
@@ -585,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
|
||||||
@@ -593,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
|
||||||
@@ -630,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,
|
||||||
@@ -660,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.
|
||||||
@@ -779,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.
|
||||||
@@ -928,6 +1073,14 @@ def _imap_connect(account_id: str | None = None, owner: str = "",
|
|||||||
# `timeout` is overridable so short-lived callers (e.g. the service-health
|
# `timeout` is overridable so short-lived callers (e.g. the service-health
|
||||||
# probe) can impose a tighter budget than the default IMAP timeout.
|
# probe) can impose a tighter budget than the default IMAP timeout.
|
||||||
cfg = _get_email_config(account_id, owner=owner)
|
cfg = _get_email_config(account_id, owner=owner)
|
||||||
|
# Send-only (SMTP-only) account: no IMAP host means there is no inbox to
|
||||||
|
# read. Bail out with a clear, typed error instead of handing an empty
|
||||||
|
# host to imaplib — IMAP4("", 993) silently dials localhost:993 and fails
|
||||||
|
# with a confusing "[Errno 111] Connection refused" on every inbox poll.
|
||||||
|
if not cfg.get("imap_host"):
|
||||||
|
raise EmailNotConfiguredError(
|
||||||
|
f"IMAP is not configured for account {cfg.get('account_name') or 'default'!r}"
|
||||||
|
)
|
||||||
# Connection mode:
|
# Connection mode:
|
||||||
# STARTTLS on → plain + upgrade
|
# STARTTLS on → plain + upgrade
|
||||||
# STARTTLS off + port 993 → implicit SSL (IMAPS)
|
# STARTTLS off + port 993 → implicit SSL (IMAPS)
|
||||||
@@ -1141,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:
|
||||||
@@ -1233,56 +1391,95 @@ def _list_attachments_from_msg(msg):
|
|||||||
return attachments
|
return attachments
|
||||||
idx = 0
|
idx = 0
|
||||||
for part in msg.walk():
|
for part in msg.walk():
|
||||||
if part.is_multipart():
|
|
||||||
continue
|
|
||||||
cd = str(part.get("Content-Disposition", ""))
|
cd = str(part.get("Content-Disposition", ""))
|
||||||
ct = part.get_content_type()
|
ct = part.get_content_type()
|
||||||
|
is_attached_email = ct == "message/rfc822" and ("attachment" in cd.lower() or part.get_filename())
|
||||||
|
if part.is_multipart() and not is_attached_email:
|
||||||
|
continue
|
||||||
# Skip text/html body parts (only consider real attachments)
|
# Skip text/html body parts (only consider real attachments)
|
||||||
if ct in ("text/plain", "text/html") and "attachment" not in cd:
|
if ct in ("text/plain", "text/html") and "attachment" not in cd:
|
||||||
continue
|
continue
|
||||||
filename = part.get_filename()
|
filename = part.get_filename()
|
||||||
if filename:
|
if filename:
|
||||||
filename = _decode_header(filename)
|
filename = _decode_header(filename)
|
||||||
|
if ct == "message/rfc822" and not re.search(r"\.[A-Za-z0-9]{1,8}$", filename):
|
||||||
|
filename = f"{filename}.eml"
|
||||||
else:
|
else:
|
||||||
# Inline images, etc. - generate a name
|
# Inline images, etc. - generate a name
|
||||||
ext = ct.split("/")[-1] if "/" in ct else "bin"
|
ext = "eml" if ct == "message/rfc822" else (ct.split("/")[-1] if "/" in ct else "bin")
|
||||||
filename = f"attachment_{idx}.{ext}"
|
filename = f"attachment_{idx}.{ext}"
|
||||||
payload = part.get_payload(decode=True)
|
payload = part.get_payload(decode=True)
|
||||||
size = len(payload) if payload else 0
|
if payload is None and ct == "message/rfc822":
|
||||||
|
try:
|
||||||
|
payload = part.as_bytes()
|
||||||
|
except Exception:
|
||||||
|
payload = b""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _is_likely_signature_image_attachment(att: dict) -> bool:
|
||||||
|
"""Match the reader's inline signature/logo image filter."""
|
||||||
|
filename = str((att or {}).get("filename") or "").lower()
|
||||||
|
if not re.search(r"\.(png|jpe?g|gif|bmp|svg|webp)$", filename):
|
||||||
|
return False
|
||||||
|
size = int((att or {}).get("size") or 0)
|
||||||
|
if re.search(r"^image\d{3,}\.(png|jpe?g|gif)$", filename):
|
||||||
|
return True
|
||||||
|
if re.search(r"^(signature|logo|sig|footer|banner)[-_\d]*\.(png|jpe?g|gif|svg)$", filename):
|
||||||
|
return True
|
||||||
|
return 0 < size < 30 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _has_visible_attachments(msg) -> bool:
|
||||||
|
"""Return True only for attachments the reader will render as chips."""
|
||||||
|
return any(
|
||||||
|
not _is_likely_signature_image_attachment(att)
|
||||||
|
for att in _list_attachments_from_msg(msg)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_attachment_to_disk(msg, index, target_dir):
|
def _extract_attachment_to_disk(msg, index, target_dir):
|
||||||
"""Extract a specific attachment to disk and return the file path."""
|
"""Extract a specific attachment to disk and return the file path."""
|
||||||
if not msg.is_multipart():
|
if not msg.is_multipart():
|
||||||
return None
|
return None
|
||||||
idx = 0
|
idx = 0
|
||||||
for part in msg.walk():
|
for part in msg.walk():
|
||||||
if part.is_multipart():
|
|
||||||
continue
|
|
||||||
cd = str(part.get("Content-Disposition", ""))
|
cd = str(part.get("Content-Disposition", ""))
|
||||||
ct = part.get_content_type()
|
ct = part.get_content_type()
|
||||||
|
is_attached_email = ct == "message/rfc822" and ("attachment" in cd.lower() or part.get_filename())
|
||||||
|
if part.is_multipart() and not is_attached_email:
|
||||||
|
continue
|
||||||
if ct in ("text/plain", "text/html") and "attachment" not in cd:
|
if ct in ("text/plain", "text/html") and "attachment" not in cd:
|
||||||
continue
|
continue
|
||||||
if idx == index:
|
if idx == index:
|
||||||
filename = part.get_filename()
|
filename = part.get_filename()
|
||||||
if filename:
|
if filename:
|
||||||
filename = _decode_header(filename)
|
filename = _decode_header(filename)
|
||||||
|
if ct == "message/rfc822" and not re.search(r"\.[A-Za-z0-9]{1,8}$", filename):
|
||||||
|
filename = f"{filename}.eml"
|
||||||
else:
|
else:
|
||||||
ext = ct.split("/")[-1] if "/" in ct else "bin"
|
ext = "eml" if ct == "message/rfc822" else (ct.split("/")[-1] if "/" in ct else "bin")
|
||||||
filename = f"attachment_{idx}.{ext}"
|
filename = f"attachment_{idx}.{ext}"
|
||||||
# Sanitize
|
# Sanitize
|
||||||
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip()
|
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip()
|
||||||
payload = part.get_payload(decode=True)
|
payload = part.get_payload(decode=True)
|
||||||
if not payload:
|
if payload is None and ct == "message/rfc822":
|
||||||
|
try:
|
||||||
|
payload = part.as_bytes()
|
||||||
|
except Exception:
|
||||||
|
payload = b""
|
||||||
|
if payload is None:
|
||||||
return None
|
return None
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filepath = target_dir / safe_name
|
filepath = target_dir / safe_name
|
||||||
@@ -1664,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.
|
||||||
|
|||||||
+178
-91
@@ -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,
|
||||||
@@ -44,6 +44,46 @@ from routes.email_helpers import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Recovers a `[{"action": ...}, ...]` JSON array from raw LLM output when the
|
||||||
|
# fenced-block strip leaves nothing usable. Runs on model output influenced by
|
||||||
|
# untrusted email bodies, so it must not backtrack: the object content class is
|
||||||
|
# `[^{}]` (brace-delimited, greedy) rather than the old `[^[\]]*?` lazy runs,
|
||||||
|
# which exploded exponentially on inputs like `[{"action"},{` + `}},{{` * N
|
||||||
|
# (CodeQL py/redos #198).
|
||||||
|
_CAL_ACTION_ARRAY_RE = re.compile(
|
||||||
|
r'\[\s*\{[^{}]*"action"[^{}]*\}\s*(?:,\s*\{[^{}]*\}\s*)*\]',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _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:
|
||||||
@@ -77,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."""
|
||||||
@@ -91,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():
|
||||||
@@ -129,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
|
||||||
@@ -156,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)
|
||||||
@@ -254,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(
|
||||||
@@ -285,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
|
||||||
@@ -303,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
|
||||||
@@ -395,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("""
|
||||||
@@ -457,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:
|
||||||
@@ -491,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.
|
||||||
@@ -499,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 "
|
||||||
@@ -551,21 +606,22 @@ 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)
|
||||||
cal_extract = re.sub(r"^```(?:json)?\s*|\s*```$", "", cal_extract, flags=re.MULTILINE).strip()
|
cal_extract = re.sub(r"^```(?:json)?\s*|\s*```$", "", cal_extract, flags=re.MULTILINE).strip()
|
||||||
if not cal_extract and _raw_original:
|
if not cal_extract and _raw_original:
|
||||||
matches = list(re.finditer(r'\[\s*\{[^[\]]*?"action"[^[\]]*?\}\s*(?:,\s*\{[^[\]]*?\}\s*)*\]', _raw_original, re.DOTALL))
|
matches = list(_CAL_ACTION_ARRAY_RE.finditer(_raw_original))
|
||||||
if matches:
|
if matches:
|
||||||
cal_extract = matches[-1].group()
|
cal_extract = matches[-1].group()
|
||||||
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
|
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
|
||||||
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
|
||||||
@@ -595,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')}")
|
||||||
@@ -675,22 +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}")
|
||||||
# Record we processed this email so we don't re-LLM next run
|
else:
|
||||||
|
# Record successfully parsed results so we don't re-LLM
|
||||||
|
# no-op emails. Transient LLM failures are retried on
|
||||||
|
# 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()
|
||||||
@@ -728,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()
|
||||||
@@ -831,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"
|
||||||
@@ -849,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
|
||||||
@@ -883,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]
|
||||||
|
|
||||||
@@ -904,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()
|
||||||
@@ -1003,6 +1068,28 @@ def _scheduled_poll_once() -> dict:
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
sid = r[0]
|
sid = r[0]
|
||||||
try:
|
try:
|
||||||
|
# Atomically claim this row before doing any work. Two
|
||||||
|
# pollers can race here (the in-process asyncio task and an
|
||||||
|
# externally cron-driven `odysseus-mail poll-scheduled`, or
|
||||||
|
# an admin running the CLI manually alongside the in-process
|
||||||
|
# one despite the ODYSSEUS_INPROCESS_POLLERS=0 guidance) -
|
||||||
|
# both can SELECT the same 'pending' row before either has
|
||||||
|
# updated its status. The UPDATE...WHERE status='pending' is
|
||||||
|
# the atomicity boundary: only the poller whose UPDATE
|
||||||
|
# actually changes a row (rowcount == 1) proceeds to send;
|
||||||
|
# a loser sees rowcount == 0 and skips it instead of sending
|
||||||
|
# a duplicate.
|
||||||
|
claim_conn = sqlite3.connect(SCHEDULED_DB)
|
||||||
|
claim_cur = claim_conn.execute(
|
||||||
|
"UPDATE scheduled_emails SET status='sending' WHERE id=? AND status='pending'",
|
||||||
|
(sid,),
|
||||||
|
)
|
||||||
|
claim_conn.commit()
|
||||||
|
claimed = claim_cur.rowcount == 1
|
||||||
|
claim_conn.close()
|
||||||
|
if not claimed:
|
||||||
|
continue
|
||||||
|
|
||||||
attachments = json.loads(r[8] or "[]")
|
attachments = json.loads(r[8] or "[]")
|
||||||
row_account_id = r[9] if len(r) > 9 else None
|
row_account_id = r[9] if len(r) > 9 else None
|
||||||
odysseus_kind = r[10] if len(r) > 10 else "scheduled"
|
odysseus_kind = r[10] if len(r) > 10 else "scheduled"
|
||||||
|
|||||||
+1939
-229
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
"""Gallery route domain package (slice 2a, #4082/#4071).
|
||||||
|
|
||||||
|
Contains gallery_routes.py and gallery_helpers.py, migrated from the flat
|
||||||
|
routes/ directory. Backward-compat shims at routes/gallery_routes.py and
|
||||||
|
routes/gallery_helpers.py re-export from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""gallery_helpers.py — extracted helpers, models, and small utilities.
|
||||||
|
|
||||||
|
Imported by gallery_routes.py."""
|
||||||
|
|
||||||
|
"""Gallery routes — browsable library for photos and AI-generated images."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from core.database import GalleryImage
|
||||||
|
from src.auth_helpers import _auth_disabled
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Request schemas ----
|
||||||
|
|
||||||
|
class GalleryPatch(BaseModel):
|
||||||
|
tags: Optional[str] = None
|
||||||
|
favorite: Optional[bool] = None
|
||||||
|
album_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- EXIF extraction ----
|
||||||
|
|
||||||
|
def _extract_exif(content: bytes) -> dict:
|
||||||
|
"""Extract EXIF metadata from image bytes. Returns dict of fields."""
|
||||||
|
result = {"width": None, "height": None}
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
img = Image.open(BytesIO(content))
|
||||||
|
# Read the raw EXIF before any transpose: exif_transpose strips the
|
||||||
|
# orientation tag and with it the parsed EXIF view.
|
||||||
|
exif = img._getexif() if hasattr(img, '_getexif') else None
|
||||||
|
|
||||||
|
# Record DISPLAY dimensions (EXIF-rotated), matching upload_handler.
|
||||||
|
# A phone photo with Orientation 6/8 is stored landscape but shown
|
||||||
|
# portrait, so the raw width/height swap the aspect ratio.
|
||||||
|
try:
|
||||||
|
from PIL import ImageOps
|
||||||
|
img = ImageOps.exif_transpose(img) or img
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result["width"] = img.width
|
||||||
|
result["height"] = img.height
|
||||||
|
|
||||||
|
if not exif:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# EXIF tag IDs
|
||||||
|
# 271=Make, 272=Model, 306=DateTime, 36867=DateTimeOriginal
|
||||||
|
# 34853=GPSInfo
|
||||||
|
result["camera_make"] = str(exif.get(271, "")).strip() or None
|
||||||
|
result["camera_model"] = str(exif.get(272, "")).strip() or None
|
||||||
|
|
||||||
|
# Date taken
|
||||||
|
for tag_id in (36867, 36868, 306): # DateTimeOriginal, DateTimeDigitized, DateTime
|
||||||
|
raw = exif.get(tag_id)
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
result["taken_at"] = datetime.strptime(str(raw).strip(), "%Y:%m:%d %H:%M:%S")
|
||||||
|
break
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# GPS
|
||||||
|
gps_info = exif.get(34853)
|
||||||
|
if gps_info and isinstance(gps_info, dict):
|
||||||
|
try:
|
||||||
|
def _to_deg(vals):
|
||||||
|
d, m, s = [float(v) for v in vals]
|
||||||
|
return d + m / 60 + s / 3600
|
||||||
|
if 2 in gps_info and 4 in gps_info:
|
||||||
|
lat = _to_deg(gps_info[2])
|
||||||
|
lng = _to_deg(gps_info[4])
|
||||||
|
if gps_info.get(1) == 'S': lat = -lat
|
||||||
|
if gps_info.get(3) == 'W': lng = -lng
|
||||||
|
result["gps_lat"] = f"{lat:.6f}"
|
||||||
|
result["gps_lng"] = f"{lng:.6f}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
# User-visible failure (photo loses metadata): surface at WARNING
|
||||||
|
# and record on the result so the upload endpoint can pass it back.
|
||||||
|
logger.warning(f"EXIF extraction failed: {e}")
|
||||||
|
result["exif_error"] = str(e)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Helpers ----
|
||||||
|
|
||||||
|
def _image_to_dict(img: GalleryImage, session_name: str = None) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": img.id,
|
||||||
|
"filename": img.filename,
|
||||||
|
"url": f"/api/generated-image/{img.filename}",
|
||||||
|
"prompt": img.prompt,
|
||||||
|
"caption": img.caption or "",
|
||||||
|
"model": img.model,
|
||||||
|
"size": img.size,
|
||||||
|
"quality": img.quality,
|
||||||
|
"tags": img.tags or "",
|
||||||
|
"ai_tags": img.ai_tags or "",
|
||||||
|
"user_tags": img.tags or "",
|
||||||
|
"session_id": img.session_id,
|
||||||
|
"session_name": session_name,
|
||||||
|
"album_id": img.album_id,
|
||||||
|
"is_active": img.is_active,
|
||||||
|
"favorite": img.favorite or False,
|
||||||
|
"taken_at": img.taken_at.isoformat() if img.taken_at else None,
|
||||||
|
"camera": f"{img.camera_make or ''} {img.camera_model or ''}".strip() or None,
|
||||||
|
"gps": {"lat": img.gps_lat, "lng": img.gps_lng} if img.gps_lat else None,
|
||||||
|
"width": img.width,
|
||||||
|
"height": img.height,
|
||||||
|
"file_size": img.file_size,
|
||||||
|
"created_at": img.created_at.isoformat() if img.created_at else None,
|
||||||
|
"updated_at": img.updated_at.isoformat() if img.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_filter(q, user, model_cls=GalleryImage):
|
||||||
|
"""Apply owner filtering to a gallery query.
|
||||||
|
|
||||||
|
``get_current_user`` returns None both in auth-disabled single-user mode
|
||||||
|
and when auth is enabled but no current user was resolved. Preserve the
|
||||||
|
single-user behavior, but fail closed for auth-enabled null-user states.
|
||||||
|
"""
|
||||||
|
if user is not None:
|
||||||
|
return q.filter(model_cls.owner == user)
|
||||||
|
if _auth_disabled():
|
||||||
|
return q
|
||||||
|
return q.filter(False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _human_size(nbytes):
|
||||||
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||||
|
if abs(nbytes) < 1024:
|
||||||
|
return f"{nbytes:.1f} {unit}"
|
||||||
|
nbytes /= 1024
|
||||||
|
return f"{nbytes:.1f} PB"
|
||||||
File diff suppressed because it is too large
Load Diff
+10
-140
@@ -1,144 +1,14 @@
|
|||||||
"""gallery_helpers.py — extracted helpers, models, and small utilities.
|
"""Backward-compat shim - canonical location is routes/gallery/gallery_helpers.py.
|
||||||
|
|
||||||
Imported by gallery_routes.py."""
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
|
that ``import routes.gallery_helpers``, ``from routes.gallery_helpers import X``,
|
||||||
|
``importlib.import_module("routes.gallery_helpers")``, and
|
||||||
|
``monkeypatch.setattr(routes.gallery_helpers, ...)`` all operate on the same
|
||||||
|
object. Keeps existing import paths working after slice 2a (#4082/#4071).
|
||||||
|
"""
|
||||||
|
|
||||||
"""Gallery routes — browsable library for photos and AI-generated images."""
|
import sys as _sys
|
||||||
|
|
||||||
import logging
|
from routes.gallery import gallery_helpers as _canonical # noqa: F401
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
_sys.modules[__name__] = _canonical
|
||||||
|
|
||||||
from core.database import GalleryImage
|
|
||||||
from src.auth_helpers import _auth_disabled
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Request schemas ----
|
|
||||||
|
|
||||||
class GalleryPatch(BaseModel):
|
|
||||||
tags: Optional[str] = None
|
|
||||||
favorite: Optional[bool] = None
|
|
||||||
album_id: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---- EXIF extraction ----
|
|
||||||
|
|
||||||
def _extract_exif(content: bytes) -> dict:
|
|
||||||
"""Extract EXIF metadata from image bytes. Returns dict of fields."""
|
|
||||||
result = {"width": None, "height": None}
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
from io import BytesIO
|
|
||||||
img = Image.open(BytesIO(content))
|
|
||||||
# Read the raw EXIF before any transpose: exif_transpose strips the
|
|
||||||
# orientation tag and with it the parsed EXIF view.
|
|
||||||
exif = img._getexif() if hasattr(img, '_getexif') else None
|
|
||||||
|
|
||||||
# Record DISPLAY dimensions (EXIF-rotated), matching upload_handler.
|
|
||||||
# A phone photo with Orientation 6/8 is stored landscape but shown
|
|
||||||
# portrait, so the raw width/height swap the aspect ratio.
|
|
||||||
try:
|
|
||||||
from PIL import ImageOps
|
|
||||||
img = ImageOps.exif_transpose(img) or img
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
result["width"] = img.width
|
|
||||||
result["height"] = img.height
|
|
||||||
|
|
||||||
if not exif:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# EXIF tag IDs
|
|
||||||
# 271=Make, 272=Model, 306=DateTime, 36867=DateTimeOriginal
|
|
||||||
# 34853=GPSInfo
|
|
||||||
result["camera_make"] = str(exif.get(271, "")).strip() or None
|
|
||||||
result["camera_model"] = str(exif.get(272, "")).strip() or None
|
|
||||||
|
|
||||||
# Date taken
|
|
||||||
for tag_id in (36867, 36868, 306): # DateTimeOriginal, DateTimeDigitized, DateTime
|
|
||||||
raw = exif.get(tag_id)
|
|
||||||
if raw:
|
|
||||||
try:
|
|
||||||
result["taken_at"] = datetime.strptime(str(raw).strip(), "%Y:%m:%d %H:%M:%S")
|
|
||||||
break
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# GPS
|
|
||||||
gps_info = exif.get(34853)
|
|
||||||
if gps_info and isinstance(gps_info, dict):
|
|
||||||
try:
|
|
||||||
def _to_deg(vals):
|
|
||||||
d, m, s = [float(v) for v in vals]
|
|
||||||
return d + m / 60 + s / 3600
|
|
||||||
if 2 in gps_info and 4 in gps_info:
|
|
||||||
lat = _to_deg(gps_info[2])
|
|
||||||
lng = _to_deg(gps_info[4])
|
|
||||||
if gps_info.get(1) == 'S': lat = -lat
|
|
||||||
if gps_info.get(3) == 'W': lng = -lng
|
|
||||||
result["gps_lat"] = f"{lat:.6f}"
|
|
||||||
result["gps_lng"] = f"{lng:.6f}"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
# User-visible failure (photo loses metadata): surface at WARNING
|
|
||||||
# and record on the result so the upload endpoint can pass it back.
|
|
||||||
logger.warning(f"EXIF extraction failed: {e}")
|
|
||||||
result["exif_error"] = str(e)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Helpers ----
|
|
||||||
|
|
||||||
def _image_to_dict(img: GalleryImage, session_name: str = None) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": img.id,
|
|
||||||
"filename": img.filename,
|
|
||||||
"url": f"/api/generated-image/{img.filename}",
|
|
||||||
"prompt": img.prompt,
|
|
||||||
"model": img.model,
|
|
||||||
"size": img.size,
|
|
||||||
"quality": img.quality,
|
|
||||||
"tags": img.tags or "",
|
|
||||||
"ai_tags": img.ai_tags or "",
|
|
||||||
"user_tags": img.tags or "",
|
|
||||||
"session_id": img.session_id,
|
|
||||||
"session_name": session_name,
|
|
||||||
"album_id": img.album_id,
|
|
||||||
"is_active": img.is_active,
|
|
||||||
"favorite": img.favorite or False,
|
|
||||||
"taken_at": img.taken_at.isoformat() if img.taken_at else None,
|
|
||||||
"camera": f"{img.camera_make or ''} {img.camera_model or ''}".strip() or None,
|
|
||||||
"gps": {"lat": img.gps_lat, "lng": img.gps_lng} if img.gps_lat else None,
|
|
||||||
"width": img.width,
|
|
||||||
"height": img.height,
|
|
||||||
"file_size": img.file_size,
|
|
||||||
"created_at": img.created_at.isoformat() if img.created_at else None,
|
|
||||||
"updated_at": img.updated_at.isoformat() if img.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _owner_filter(q, user, model_cls=GalleryImage):
|
|
||||||
"""Apply owner filtering to a gallery query.
|
|
||||||
|
|
||||||
``get_current_user`` returns None both in auth-disabled single-user mode
|
|
||||||
and when auth is enabled but no current user was resolved. Preserve the
|
|
||||||
single-user behavior, but fail closed for auth-enabled null-user states.
|
|
||||||
"""
|
|
||||||
if user is not None:
|
|
||||||
return q.filter(model_cls.owner == user)
|
|
||||||
if _auth_disabled():
|
|
||||||
return q
|
|
||||||
return q.filter(False)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _human_size(nbytes):
|
|
||||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
|
||||||
if abs(nbytes) < 1024:
|
|
||||||
return f"{nbytes:.1f} {unit}"
|
|
||||||
nbytes /= 1024
|
|
||||||
return f"{nbytes:.1f} PB"
|
|
||||||
|
|||||||
+12
-1922
File diff suppressed because it is too large
Load Diff
@@ -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,794 @@
|
|||||||
|
"""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.auth_helpers import effective_user
|
||||||
|
from src.topic_analyzer import analyze_topics
|
||||||
|
from src.upload_handler import reserve_message_upload_references
|
||||||
|
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, upload_handler=None) -> APIRouter:
|
||||||
|
router = APIRouter(tags=["history"])
|
||||||
|
|
||||||
|
def _reserve_message_uploads(
|
||||||
|
request: Request,
|
||||||
|
content: Any,
|
||||||
|
metadata: Any = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
missing_id = reserve_message_upload_references(
|
||||||
|
upload_handler,
|
||||||
|
effective_user(request),
|
||||||
|
content,
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(400, "Invalid message attachment metadata") from exc
|
||||||
|
if missing_id:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
metadata = body.get("metadata")
|
||||||
|
_reserve_message_uploads(request, content, metadata)
|
||||||
|
msg = ChatMessage(role=role, content=content, metadata=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")
|
||||||
|
|
||||||
|
_reserve_message_uploads(request, content)
|
||||||
|
|
||||||
|
session = session_manager.get_session(session_id)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db_msg = db.query(DbChatMessage).filter(
|
||||||
|
DbChatMessage.id == msg_id,
|
||||||
|
DbChatMessage.session_id == session_id,
|
||||||
|
).first()
|
||||||
|
if not db_msg:
|
||||||
|
raise HTTPException(404, "Message not found")
|
||||||
|
|
||||||
|
db_msg.content = content
|
||||||
|
meta = {}
|
||||||
|
if db_msg.meta_data:
|
||||||
|
try: meta = json.loads(db_msg.meta_data)
|
||||||
|
except (json.JSONDecodeError, ValueError): pass
|
||||||
|
meta['edited'] = True
|
||||||
|
db_msg.meta_data = json.dumps(meta)
|
||||||
|
|
||||||
|
# Update in-memory history by matching _db_id
|
||||||
|
for hmsg in session.history:
|
||||||
|
hmeta = hmsg.metadata if isinstance(hmsg, ChatMessage) else hmsg.get('metadata')
|
||||||
|
if isinstance(hmeta, dict) and hmeta.get('_db_id') == msg_id:
|
||||||
|
if isinstance(hmsg, ChatMessage):
|
||||||
|
hmsg.content = content
|
||||||
|
hmsg.metadata['edited'] = True
|
||||||
|
elif isinstance(hmsg, dict):
|
||||||
|
hmsg['content'] = content
|
||||||
|
hmsg['metadata']['edited'] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Edit message error {session_id}: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
@router.post("/api/session/{session_id}/mark-stopped")
|
||||||
|
async def mark_stopped(request: Request, session_id: str):
|
||||||
|
"""Mark the last assistant message as stopped by user."""
|
||||||
|
_verify_session_owner(request, session_id)
|
||||||
|
try:
|
||||||
|
session = session_manager.get_session(session_id)
|
||||||
|
# Find last assistant message and add stopped metadata
|
||||||
|
for msg in reversed(session.history):
|
||||||
|
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
|
||||||
|
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
|
||||||
|
if isinstance(msg, ChatMessage):
|
||||||
|
if not msg.metadata:
|
||||||
|
msg.metadata = {}
|
||||||
|
msg.metadata['stopped'] = True
|
||||||
|
if not msg.metadata.get('model'):
|
||||||
|
msg.metadata['model'] = session.model
|
||||||
|
else:
|
||||||
|
if 'metadata' not in msg:
|
||||||
|
msg['metadata'] = {}
|
||||||
|
msg['metadata']['stopped'] = True
|
||||||
|
if not msg['metadata'].get('model'):
|
||||||
|
msg['metadata']['model'] = session.model
|
||||||
|
break
|
||||||
|
# Also update in DB
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
db_messages = (
|
||||||
|
db.query(DbChatMessage)
|
||||||
|
.filter(DbChatMessage.session_id == session_id, DbChatMessage.role == 'assistant')
|
||||||
|
.order_by(DbChatMessage.timestamp.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if db_messages:
|
||||||
|
meta = {}
|
||||||
|
if db_messages.meta_data:
|
||||||
|
try:
|
||||||
|
meta = _json.loads(db_messages.meta_data)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
meta['stopped'] = True
|
||||||
|
if not meta.get('model'):
|
||||||
|
meta['model'] = session.model
|
||||||
|
db_messages.meta_data = _json.dumps(meta)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
session_manager.save_sessions()
|
||||||
|
return {"status": "ok"}
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Mark stopped error {session_id}: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
@router.post("/api/session/{session_id}/update-last-meta")
|
||||||
|
async def update_last_meta(request: Request, session_id: str):
|
||||||
|
"""Merge metadata into the last assistant message (e.g. save variants)."""
|
||||||
|
_verify_session_owner(request, session_id)
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
meta_update = body.get("metadata", {})
|
||||||
|
session = session_manager.get_session(session_id)
|
||||||
|
|
||||||
|
# Update in-memory
|
||||||
|
for msg in reversed(session.history):
|
||||||
|
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
|
||||||
|
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
|
||||||
|
if isinstance(msg, ChatMessage):
|
||||||
|
if not msg.metadata:
|
||||||
|
msg.metadata = {}
|
||||||
|
msg.metadata.update(meta_update)
|
||||||
|
else:
|
||||||
|
if 'metadata' not in msg:
|
||||||
|
msg['metadata'] = {}
|
||||||
|
msg['metadata'].update(meta_update)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Update in DB
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
db_msg = (
|
||||||
|
db.query(DbChatMessage)
|
||||||
|
.filter(DbChatMessage.session_id == session_id, DbChatMessage.role == 'assistant')
|
||||||
|
.order_by(DbChatMessage.timestamp.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if db_msg:
|
||||||
|
meta = {}
|
||||||
|
if db_msg.meta_data:
|
||||||
|
try: meta = _json.loads(db_msg.meta_data)
|
||||||
|
except (json.JSONDecodeError, ValueError): pass
|
||||||
|
meta.update(meta_update)
|
||||||
|
db_msg.meta_data = _json.dumps(meta)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
session_manager.save_sessions()
|
||||||
|
return {"status": "ok"}
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Update last meta error {session_id}: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
@router.post("/api/session/{session_id}/merge-last-assistant")
|
||||||
|
async def merge_last_assistant(request: Request, session_id: str):
|
||||||
|
"""Merge the last two assistant messages into one (for continue)."""
|
||||||
|
_verify_session_owner(request, session_id)
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
separator = body.get("separator", "\n\n")
|
||||||
|
session = session_manager.get_session(session_id)
|
||||||
|
|
||||||
|
# Find last two assistant messages in-memory
|
||||||
|
ai_indices = []
|
||||||
|
for i, msg in enumerate(session.history):
|
||||||
|
role = msg.role if isinstance(msg, ChatMessage) else msg.get('role', '')
|
||||||
|
if role == 'assistant':
|
||||||
|
ai_indices.append(i)
|
||||||
|
|
||||||
|
if len(ai_indices) < 2:
|
||||||
|
return {"status": "ok", "merged": False}
|
||||||
|
|
||||||
|
idx1, idx2 = ai_indices[-2], ai_indices[-1]
|
||||||
|
msg1, msg2 = session.history[idx1], session.history[idx2]
|
||||||
|
|
||||||
|
content1 = msg1.content if isinstance(msg1, ChatMessage) else msg1.get('content', '')
|
||||||
|
content2 = msg2.content if isinstance(msg2, ChatMessage) else msg2.get('content', '')
|
||||||
|
merged_content = content1 + separator + content2
|
||||||
|
|
||||||
|
# Merge metadata
|
||||||
|
meta1 = (msg1.metadata if isinstance(msg1, ChatMessage) else msg1.get('metadata')) or {}
|
||||||
|
meta2 = (msg2.metadata if isinstance(msg2, ChatMessage) else msg2.get('metadata')) or {}
|
||||||
|
merged_meta = {**meta1, **meta2}
|
||||||
|
merged_meta.pop('stopped', None) # no longer stopped after continue
|
||||||
|
|
||||||
|
# Update first message, remove second
|
||||||
|
if isinstance(msg1, ChatMessage):
|
||||||
|
msg1.content = merged_content
|
||||||
|
msg1.metadata = merged_meta
|
||||||
|
else:
|
||||||
|
msg1['content'] = merged_content
|
||||||
|
msg1['metadata'] = merged_meta
|
||||||
|
|
||||||
|
# Also remove the hidden "continue" user message between them if present
|
||||||
|
# It's the message at idx2-1 if it's a user message with continue text
|
||||||
|
remove_indices = [idx2]
|
||||||
|
if idx2 - 1 > idx1:
|
||||||
|
between = session.history[idx2 - 1]
|
||||||
|
between_role = between.role if isinstance(between, ChatMessage) else between.get('role', '')
|
||||||
|
between_content = between.content if isinstance(between, ChatMessage) else between.get('content', '')
|
||||||
|
if between_role == 'user' and 'previous response was interrupted' in between_content:
|
||||||
|
remove_indices.insert(0, idx2 - 1)
|
||||||
|
|
||||||
|
for ri in sorted(remove_indices, reverse=True):
|
||||||
|
session.history.pop(ri)
|
||||||
|
|
||||||
|
# Update DB
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
db_messages = (
|
||||||
|
db.query(DbChatMessage)
|
||||||
|
.filter(DbChatMessage.session_id == session_id)
|
||||||
|
.order_by(DbChatMessage.timestamp)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# Find last two assistant messages in DB
|
||||||
|
ai_db = [(i, m) for i, m in enumerate(db_messages) if m.role == 'assistant']
|
||||||
|
if len(ai_db) >= 2:
|
||||||
|
(_, db1), (_, db2) = ai_db[-2], ai_db[-1]
|
||||||
|
db1.content = merged_content
|
||||||
|
db1.meta_data = _json.dumps(merged_meta)
|
||||||
|
|
||||||
|
# Mirror the in-memory deletion: remove the second assistant
|
||||||
|
# message and ONLY the "continue" user message between them
|
||||||
|
# (not arbitrary tool/system/user rows). The old
|
||||||
|
# range-delete destroyed every row between the two assistant
|
||||||
|
# messages, desyncing the DB from the in-memory history.
|
||||||
|
for _row in _merge_continue_rows_to_delete(db_messages, db1, db2):
|
||||||
|
db.delete(_row)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
session_manager.save_sessions()
|
||||||
|
return {"status": "ok", "merged": True}
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Merge assistant error {session_id}: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
@router.post("/api/session/{session_id}/fork")
|
||||||
|
async def fork_session(request: Request, session_id: str):
|
||||||
|
"""Create a new session with messages copied up to keep_count."""
|
||||||
|
_verify_session_owner(request, session_id)
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
keep_count = body.get("keep_count", 0)
|
||||||
|
|
||||||
|
# Get the source session
|
||||||
|
source = session_manager.sessions.get(session_id)
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
|
||||||
|
# Create new session
|
||||||
|
new_id = str(uuid.uuid4())
|
||||||
|
fork_name = f"\u2ADD {source.name}"
|
||||||
|
new_session = session_manager.create_session(
|
||||||
|
session_id=new_id,
|
||||||
|
name=fork_name,
|
||||||
|
endpoint_url=source.endpoint_url,
|
||||||
|
model=source.model,
|
||||||
|
rag=False,
|
||||||
|
owner=getattr(source, 'owner', None),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy messages up to keep_count
|
||||||
|
msgs_to_copy = source.history[:keep_count]
|
||||||
|
for msg in msgs_to_copy:
|
||||||
|
# Copy the metadata dict. Sharing it would let the fork's
|
||||||
|
# persistence (add_message -> _persist_message stamps
|
||||||
|
# _db_id/timestamp onto the dict) mutate the SOURCE session's
|
||||||
|
# in-memory messages, corrupting their _db_id and breaking
|
||||||
|
# edit/delete-by-id on the original conversation.
|
||||||
|
meta = dict(msg.metadata) if isinstance(msg.metadata, dict) else None
|
||||||
|
new_session.add_message(ChatMessage(msg.role, msg.content, meta))
|
||||||
|
try:
|
||||||
|
from src.event_bus import fire_event
|
||||||
|
fire_event("session_created", getattr(source, 'owner', None))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"id": new_id,
|
||||||
|
"name": fork_name,
|
||||||
|
"kept": len(msgs_to_copy),
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fork error {session_id}: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
@router.get("/api/conversations/topics")
|
||||||
|
async def get_conversation_topics(request: Request) -> Dict[str, Any]:
|
||||||
|
from src.auth_helpers import require_user
|
||||||
|
user = require_user(request)
|
||||||
|
try:
|
||||||
|
return analyze_topics(session_manager, owner=user or None)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Topic analysis failed: {e}")
|
||||||
|
|
||||||
|
@router.post("/api/session/{session_id}/compact")
|
||||||
|
async def compact_session(request: Request, session_id: str):
|
||||||
|
"""Manually trigger context compaction for a session."""
|
||||||
|
_verify_session_owner(request, session_id)
|
||||||
|
from src.auth_helpers import effective_user
|
||||||
|
owner = effective_user(request)
|
||||||
|
try:
|
||||||
|
session = session_manager.get_session(session_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
_reject_compact_during_active_run(session_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.model_context import estimate_tokens, get_context_length
|
||||||
|
from src.llm_core import llm_call_async
|
||||||
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
|
||||||
|
if len(session.history) < 6:
|
||||||
|
return {"status": "ok", "message": "Not enough messages to compact"}
|
||||||
|
|
||||||
|
ctx_len = get_context_length(session.endpoint_url, session.model)
|
||||||
|
messages_before = session.get_context_messages()
|
||||||
|
used_before = estimate_tokens(messages_before)
|
||||||
|
pct_before = round((used_before / ctx_len) * 100, 1) if ctx_len else 0
|
||||||
|
msg_count_before = len(session.history)
|
||||||
|
|
||||||
|
# Keep only last 4 messages, summarize the rest
|
||||||
|
keep_count = 4
|
||||||
|
older = session.history[:-keep_count]
|
||||||
|
recent = session.history[-keep_count:]
|
||||||
|
|
||||||
|
# Build text to summarize
|
||||||
|
convo_text = "\n".join(
|
||||||
|
f"{_message_role(m).upper()}: "
|
||||||
|
f"{_message_text(m)[:2000]}"
|
||||||
|
for m in older
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use utility model if available
|
||||||
|
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
|
||||||
|
compact_url = util_url or session.endpoint_url
|
||||||
|
compact_model = util_model or session.model
|
||||||
|
compact_headers = util_headers if util_url else session.headers
|
||||||
|
|
||||||
|
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
|
||||||
|
compaction_count = sum(1 for m in session.history if isinstance(m, ChatMessage) and "[Conversation summary" in (m.content or ""))
|
||||||
|
sys_prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace("{count}", str(len(older))).replace("{n}", str(compaction_count + 1))
|
||||||
|
summary = await llm_call_async(
|
||||||
|
compact_url, compact_model,
|
||||||
|
[
|
||||||
|
{"role": "system", "content": sys_prompt},
|
||||||
|
{"role": "user", "content": convo_text},
|
||||||
|
],
|
||||||
|
temperature=0.2, max_tokens=1024,
|
||||||
|
headers=compact_headers, timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace session history: summary as system message + recent messages
|
||||||
|
# System message holds the full summary for AI context
|
||||||
|
system_summary = ChatMessage(
|
||||||
|
role="system",
|
||||||
|
content=f"[Conversation summary — {len(older)} earlier messages were compacted]\n\n{summary}",
|
||||||
|
metadata={"compacted": True, "hidden": True},
|
||||||
|
)
|
||||||
|
# Visible assistant message just shows stats
|
||||||
|
summary_msg = ChatMessage(
|
||||||
|
role="assistant",
|
||||||
|
content=f"**Conversation compacted** — {len(older)} messages summarized, {len(recent)} kept.",
|
||||||
|
metadata={"compacted": True, "messages_removed": len(older)},
|
||||||
|
)
|
||||||
|
new_history = [system_summary, summary_msg] + list(recent)
|
||||||
|
session.history = new_history
|
||||||
|
session.message_count = len(session.history)
|
||||||
|
logger.info(f"Compact: session {session_id} history now has {len(session.history)} messages (was {msg_count_before})")
|
||||||
|
|
||||||
|
# Update DB: delete old messages, insert summary
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db_msgs = db.query(DbChatMessage).filter(
|
||||||
|
DbChatMessage.session_id == session_id
|
||||||
|
).order_by(DbChatMessage.timestamp).all()
|
||||||
|
|
||||||
|
# Delete all but the last keep_count
|
||||||
|
for m in db_msgs[:-keep_count]:
|
||||||
|
db.delete(m)
|
||||||
|
|
||||||
|
# Insert system summary (hidden, for AI context) and visible summary
|
||||||
|
import json as _json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
db_sys_summary = DbChatMessage(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
session_id=session_id,
|
||||||
|
role="system",
|
||||||
|
content=system_summary.content,
|
||||||
|
meta_data=_json.dumps(system_summary.metadata),
|
||||||
|
timestamp=now,
|
||||||
|
)
|
||||||
|
db.add(db_sys_summary)
|
||||||
|
db_summary = DbChatMessage(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
session_id=session_id,
|
||||||
|
role="assistant",
|
||||||
|
content=summary_msg.content,
|
||||||
|
meta_data=_json.dumps(summary_msg.metadata),
|
||||||
|
timestamp=now,
|
||||||
|
)
|
||||||
|
db.add(db_summary)
|
||||||
|
|
||||||
|
# Update session record
|
||||||
|
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||||
|
if db_session:
|
||||||
|
db_session.message_count = len(session.history)
|
||||||
|
db_session.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
session_manager.save_sessions()
|
||||||
|
|
||||||
|
used_after = estimate_tokens(session.get_context_messages())
|
||||||
|
pct_after = round((used_after / ctx_len) * 100, 1) if ctx_len else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Compacted: {msg_count_before} msgs → {len(session.history)} msgs ({pct_before}% → {pct_after}%)",
|
||||||
|
"before": pct_before,
|
||||||
|
"after": pct_after,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Manual compact error {session_id}: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
return router
|
||||||
+13
-658
@@ -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
|
||||||
|
all operate on the *same* object the application actually uses. Keeps existing
|
||||||
|
import paths working after slice 2d (#4082/#4071). Source-introspection tests
|
||||||
|
read the canonical file by path.
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException
|
import sys as _sys
|
||||||
|
|
||||||
from core.models import ChatMessage
|
from routes.history import history_routes as _canonical # noqa: F401
|
||||||
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__)
|
_sys.modules[__name__] = _canonical
|
||||||
|
|
||||||
|
|
||||||
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"])
|
|
||||||
|
|
||||||
@router.get("/api/history/{session_id}")
|
|
||||||
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
|
|
||||||
|
|||||||
+123
-7
@@ -1,8 +1,13 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from core.platform_compat import run_ssh_command
|
||||||
from routes._validators import validate_remote_host, validate_ssh_port
|
from routes._validators import validate_remote_host, validate_ssh_port
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +112,73 @@ def _apply_manual_hardware(system, manual_mode="", manual_gpu_count="", manual_v
|
|||||||
return system
|
return system
|
||||||
|
|
||||||
|
|
||||||
|
def _run_model_probe(host: str, ssh_port: str, cmd: str) -> str:
|
||||||
|
try:
|
||||||
|
if host:
|
||||||
|
r = run_ssh_command(
|
||||||
|
host,
|
||||||
|
ssh_port or None,
|
||||||
|
cmd,
|
||||||
|
timeout=15,
|
||||||
|
connect_timeout=5,
|
||||||
|
strict_host_key_checking=False,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
r = subprocess.run(["bash", "-lc", cmd], capture_output=True, text=True, timeout=15)
|
||||||
|
if r.returncode == 0:
|
||||||
|
return (r.stdout or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect_model_path(model_path: str, host: str = "", ssh_port: str = "") -> dict:
|
||||||
|
"""Read lightweight metadata from a local or SSH-visible HF model folder."""
|
||||||
|
path = (model_path or "").strip()
|
||||||
|
if not path or path.startswith(("http://", "https://")):
|
||||||
|
return {}
|
||||||
|
if not (path.startswith("/") or path.startswith("~")):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
qpath = shlex.quote(path)
|
||||||
|
qconfig = shlex.quote(os.path.join(path, "config.json"))
|
||||||
|
out = {}
|
||||||
|
exists = _run_model_probe(host, ssh_port, f"test -d {qpath} && printf found || printf missing")
|
||||||
|
if exists != "found":
|
||||||
|
target = host or "local container"
|
||||||
|
out["model_probe_error"] = f"Model path is not visible on {target}: {path}"
|
||||||
|
return out
|
||||||
|
raw_config = _run_model_probe(host, ssh_port, f"test -f {qconfig} && sed -n '1,240p' {qconfig}")
|
||||||
|
if raw_config:
|
||||||
|
try:
|
||||||
|
cfg = json.loads(raw_config)
|
||||||
|
except Exception:
|
||||||
|
cfg = {}
|
||||||
|
for key in ("context_length", "max_position_embeddings", "n_ctx_train", "model_max_length", "max_seq_len"):
|
||||||
|
value = cfg.get(key)
|
||||||
|
if isinstance(value, (int, float)) and value > 0:
|
||||||
|
out["model_ctx_max"] = int(value)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
out["model_probe_error"] = f"config.json not found in model path: {path}"
|
||||||
|
|
||||||
|
size_cmd = (
|
||||||
|
f"find {qpath} -type f \\( -name '*.safetensors' -o -name '*.bin' -o -name '*.gguf' \\) "
|
||||||
|
"-printf '%s\\n' 2>/dev/null | awk '{s+=$1} END {if (s>0) printf \"%.6f\", s/1073741824}'"
|
||||||
|
)
|
||||||
|
weights = _run_model_probe(host, ssh_port, size_cmd)
|
||||||
|
try:
|
||||||
|
weights_gb = float(weights)
|
||||||
|
except Exception:
|
||||||
|
weights_gb = 0.0
|
||||||
|
if weights_gb > 0:
|
||||||
|
out["model_weights_gb"] = round(weights_gb, 3)
|
||||||
|
elif "model_probe_error" not in out:
|
||||||
|
out["model_probe_error"] = f"No model weight files found in: {path}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def setup_hwfit_routes():
|
def setup_hwfit_routes():
|
||||||
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"])
|
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"])
|
||||||
|
|
||||||
@@ -119,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
|
||||||
@@ -128,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,
|
||||||
@@ -232,10 +310,13 @@ 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 = "", 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 = ""):
|
||||||
"""Compute llama.cpp serve profiles (Quality/Balanced/Speed) for `model`
|
"""Compute llama.cpp serve profiles (Quality/Balanced/Speed) for `model`
|
||||||
against the detected hardware on `host` (or local). Returns concrete
|
against the detected hardware on `host` (or local). Returns concrete
|
||||||
flags (n_gpu_layers, n_cpu_moe, cache_type, ctx) the serve UI can apply.
|
flags (n_gpu_layers, n_cpu_moe, cache_type, ctx) the serve UI can apply.
|
||||||
@@ -260,8 +341,23 @@ def setup_hwfit_routes():
|
|||||||
# "deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct".
|
# "deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct".
|
||||||
s = (s or "").lower().strip()
|
s = (s or "").lower().strip()
|
||||||
s = s.split("/")[-1] # drop org prefix
|
s = s.split("/")[-1] # drop org prefix
|
||||||
s = re.sub(r"[-_.]?gguf$", "", s) # drop trailing gguf marker
|
for suffix in ("-gguf", "_gguf", ".gguf", "gguf"):
|
||||||
s = re.sub(r"[-_.](q\d[^/]*|iq\d[^/]*|fp8|bf16|f16|awq[^/]*|gptq[^/]*)$", "", s)
|
if s.endswith(suffix):
|
||||||
|
s = s[: -len(suffix)]
|
||||||
|
break
|
||||||
|
cut_at = None
|
||||||
|
for idx, ch in enumerate(s):
|
||||||
|
if ch not in "-_." or idx + 1 >= len(s):
|
||||||
|
continue
|
||||||
|
suffix = s[idx + 1:]
|
||||||
|
if (
|
||||||
|
suffix in {"fp8", "bf16", "f16"}
|
||||||
|
or suffix.startswith(("awq", "gptq", "iq"))
|
||||||
|
or (suffix.startswith("q") and len(suffix) > 1 and suffix[1].isdigit())
|
||||||
|
):
|
||||||
|
cut_at = idx
|
||||||
|
if cut_at is not None:
|
||||||
|
s = s[:cut_at]
|
||||||
return s
|
return s
|
||||||
|
|
||||||
m = catalog.get(model)
|
m = catalog.get(model)
|
||||||
@@ -272,8 +368,16 @@ def setup_hwfit_routes():
|
|||||||
if nn and (nn == want or want.endswith(nn) or nn.endswith(want)):
|
if nn and (nn == want or want.endswith(nn) or nn.endswith(want)):
|
||||||
m = entry
|
m = entry
|
||||||
break
|
break
|
||||||
|
path_meta = _inspect_model_path(model_path or model, host=host, ssh_port=ssh_port)
|
||||||
if m is None:
|
if m is None:
|
||||||
return {"system": system, "profiles": [], "error": "model not in catalog"}
|
return {
|
||||||
|
"system": system,
|
||||||
|
"profiles": [],
|
||||||
|
"error": "model not in catalog",
|
||||||
|
"model_ctx_max": int(path_meta.get("model_ctx_max") or 0),
|
||||||
|
"model_weights_gb": float(path_meta.get("model_weights_gb") or 0),
|
||||||
|
"model_probe_error": path_meta.get("model_probe_error") or "",
|
||||||
|
}
|
||||||
# Surface the model's trained context limit so the serve UI can clamp a
|
# Surface the model's trained context limit so the serve UI can clamp a
|
||||||
# user-typed context down to it (asking for ctx > n_ctx_train overflows
|
# user-typed context down to it (asking for ctx > n_ctx_train overflows
|
||||||
# and, with a quantized KV cache, can crash the GPU).
|
# and, with a quantized KV cache, can crash the GPU).
|
||||||
@@ -283,6 +387,16 @@ def setup_hwfit_routes():
|
|||||||
if isinstance(v, (int, float)) and v > 0:
|
if isinstance(v, (int, float)) and v > 0:
|
||||||
model_ctx_max = int(v)
|
model_ctx_max = int(v)
|
||||||
break
|
break
|
||||||
|
path_ctx_max = int(path_meta.get("model_ctx_max") or 0)
|
||||||
|
if path_ctx_max > 0:
|
||||||
|
model_ctx_max = max(model_ctx_max, path_ctx_max)
|
||||||
|
model_weights_gb = float(path_meta.get("model_weights_gb") or 0)
|
||||||
|
if model_weights_gb <= 0:
|
||||||
|
for k in ("min_vram_gb", "required_gb", "size_gb", "recommended_ram_gb", "min_ram_gb"):
|
||||||
|
v = m.get(k)
|
||||||
|
if isinstance(v, (int, float)) and v > 0:
|
||||||
|
model_weights_gb = float(v)
|
||||||
|
break
|
||||||
return {
|
return {
|
||||||
"system": system,
|
"system": system,
|
||||||
"profiles": compute_serve_profiles(
|
"profiles": compute_serve_profiles(
|
||||||
@@ -291,6 +405,8 @@ def setup_hwfit_routes():
|
|||||||
serve_quant=(serve_quant or None),
|
serve_quant=(serve_quant or None),
|
||||||
),
|
),
|
||||||
"model_ctx_max": model_ctx_max,
|
"model_ctx_max": model_ctx_max,
|
||||||
|
"model_weights_gb": model_weights_gb,
|
||||||
|
"model_probe_error": path_meta.get("model_probe_error") or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/image-models")
|
@router.get("/image-models")
|
||||||
|
|||||||
@@ -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
|
||||||
+14
-548
@@ -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
|
||||||
|
slice 2c (#4082/#4071). Source-introspection tests read the canonical file
|
||||||
|
by path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
def _strip_list_prefix(text: str) -> str:
|
from routes.memory import memory_routes as _canonical # noqa: F401
|
||||||
if not text:
|
|
||||||
return text
|
|
||||||
return _LIST_PREFIX_RE.sub("", text, count=1).strip()
|
|
||||||
|
|
||||||
from services.memory import MemoryManager
|
_sys.modules[__name__] = _canonical
|
||||||
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
|
|
||||||
|
|||||||
+282
-62
@@ -17,7 +17,9 @@ from fastapi import APIRouter, HTTPException, Form, Query, Body, Request, Respon
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
||||||
|
from core.log_safety import redact_url as _redact_url_for_log
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
|
from src.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
|
||||||
@@ -111,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):
|
||||||
@@ -124,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
|
||||||
@@ -136,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,
|
||||||
@@ -406,8 +493,11 @@ def _endpoint_refresh_timeout(ep: Any, category: str) -> float:
|
|||||||
except Exception:
|
except Exception:
|
||||||
val = 0
|
val = 0
|
||||||
if val > 0:
|
if val > 0:
|
||||||
return float(max(1, min(30, val)))
|
return float(max(1, min(60, val)))
|
||||||
return 2.5 if category == "local" else 2.0
|
# llama.cpp and other local OpenAI-compatible servers can block briefly
|
||||||
|
# while warming/loading. A 2s local timeout makes working endpoints flicker
|
||||||
|
# offline before /v1/models is ready.
|
||||||
|
return 10.0 if category == "local" else 2.0
|
||||||
|
|
||||||
|
|
||||||
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
|
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
|
||||||
@@ -474,7 +564,7 @@ def _explicit_model_list_timeout(base_url: str, endpoint_kind: str = "auto", req
|
|||||||
category = _classify_endpoint(base_url, kind)
|
category = _classify_endpoint(base_url, kind)
|
||||||
if kind in ("api", "proxy") or category == "api":
|
if kind in ("api", "proxy") or category == "api":
|
||||||
return 30.0
|
return 30.0
|
||||||
return 3.0 if _is_ollama_base(base_url) else 2.0
|
return 15.0 if category == "local" else (3.0 if _is_ollama_base(base_url) else 2.0)
|
||||||
|
|
||||||
|
|
||||||
def _cached_model_ids(ep: Any) -> List[str]:
|
def _cached_model_ids(ep: Any) -> List[str]:
|
||||||
@@ -519,6 +609,10 @@ _NON_CHAT_EXACT_PREFIXES = (
|
|||||||
|
|
||||||
def _is_chat_model(model_id: str) -> bool:
|
def _is_chat_model(model_id: str) -> bool:
|
||||||
"""Return True if the model ID looks like a chat/completions-capable model."""
|
"""Return True if the model ID looks like a chat/completions-capable model."""
|
||||||
|
if not isinstance(model_id, str):
|
||||||
|
# Non-compliant upstreams can return non-string IDs (e.g. int/None);
|
||||||
|
# treat them as chat-capable rather than crashing on .lower().
|
||||||
|
return True
|
||||||
mid = model_id.lower()
|
mid = model_id.lower()
|
||||||
for prefix in _NON_CHAT_PREFIXES:
|
for prefix in _NON_CHAT_PREFIXES:
|
||||||
if mid.startswith(prefix):
|
if mid.startswith(prefix):
|
||||||
@@ -711,6 +805,51 @@ def _effective_endpoint_kind(ep: Any, base_url: str) -> str:
|
|||||||
return "auto"
|
return "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_loading_model_response(resp: Any) -> bool:
|
||||||
|
if getattr(resp, "status_code", None) != 503:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
body = resp.text or ""
|
||||||
|
except Exception:
|
||||||
|
body = ""
|
||||||
|
return "loading model" in body.lower()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_model_ids(data: Any) -> List[str]:
|
||||||
|
"""Extract OpenAI-style model IDs.
|
||||||
|
|
||||||
|
Accepts both standard ``{"data": [{"id": ...}]}`` responses and bare
|
||||||
|
``[{"id": ...}]`` lists returned by some OpenAI-compatible providers.
|
||||||
|
Tolerates non-dict/non-list bodies and non-string IDs, returning only
|
||||||
|
non-empty string IDs.
|
||||||
|
"""
|
||||||
|
if isinstance(data, list):
|
||||||
|
items = data
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
items = data.get("data")
|
||||||
|
else:
|
||||||
|
items = None
|
||||||
|
return [m["id"] for m in (items or [])
|
||||||
|
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_model_names(data: Any) -> List[str]:
|
||||||
|
"""Extract native-Ollama model names (``{"models": [{"name"|"model": ...}]}``).
|
||||||
|
|
||||||
|
Same tolerance as :func:`_openai_model_ids`: a non-dict body or non-string
|
||||||
|
value is skipped rather than crashing, preserving name-then-model precedence.
|
||||||
|
"""
|
||||||
|
items = data.get("models") if isinstance(data, dict) else None
|
||||||
|
out: List[str] = []
|
||||||
|
for m in (items or []):
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
v = m.get("name") or m.get("model")
|
||||||
|
if isinstance(v, str) and v:
|
||||||
|
out.append(v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]:
|
def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]:
|
||||||
"""Probe a base URL's /models endpoint and return list of model IDs.
|
"""Probe a base URL's /models endpoint and return list of model IDs.
|
||||||
@@ -734,7 +873,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
models = _openai_model_ids(data)
|
||||||
if models:
|
if models:
|
||||||
return models
|
return models
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
@@ -756,10 +895,10 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
# OpenAI format: {"data": [{"id": "model-name"}]}
|
# OpenAI format: {"data": [{"id": "model-name"}]}
|
||||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
models = _openai_model_ids(data)
|
||||||
# Ollama format: {"models": [{"name": "model-name"}]}
|
# Ollama format: {"models": [{"name": "model-name"}]}
|
||||||
if not models:
|
if not models:
|
||||||
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
|
models = _ollama_model_names(data)
|
||||||
if models:
|
if models:
|
||||||
# Z.AI coding plan omits some working models from /models;
|
# Z.AI coding plan omits some working models from /models;
|
||||||
# append curated-only entries for that endpoint only.
|
# append curated-only entries for that endpoint only.
|
||||||
@@ -775,16 +914,19 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
models.append(_e)
|
models.append(_e)
|
||||||
return [m for m in models if _is_chat_model(m)]
|
return [m for m in models if _is_chat_model(m)]
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response is not None and _is_loading_model_response(e.response):
|
||||||
|
logger.info("Endpoint still loading model at %s", _redact_url_for_log(url))
|
||||||
|
return []
|
||||||
if api_key:
|
if api_key:
|
||||||
status = e.response.status_code if e.response is not None else "unknown"
|
status = e.response.status_code if e.response is not None else "unknown"
|
||||||
logger.warning(f"Failed to probe {url} with API key: HTTP {status}")
|
logger.warning("Failed to probe %s with API key: HTTP %s", _redact_url_for_log(url), status)
|
||||||
return []
|
return []
|
||||||
logger.warning(f"Failed to probe {url}: {e}")
|
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if api_key:
|
if api_key:
|
||||||
logger.warning(f"Failed to probe {url} with API key: {e}")
|
logger.warning("Failed to probe %s with API key: %s", _redact_url_for_log(url), e)
|
||||||
return []
|
return []
|
||||||
logger.warning(f"Failed to probe {url}: {e}")
|
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
||||||
|
|
||||||
# Older Ollama builds and some proxies expose native /api/tags even when
|
# Older Ollama builds and some proxies expose native /api/tags even when
|
||||||
# the OpenAI-compatible /v1/models path is unavailable.
|
# the OpenAI-compatible /v1/models path is unavailable.
|
||||||
@@ -795,7 +937,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
r = httpx.get(root + "/api/tags", timeout=timeout, verify=llm_verify())
|
r = httpx.get(root + "/api/tags", timeout=timeout, verify=llm_verify())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")]
|
models = _ollama_model_names(data)
|
||||||
if models:
|
if models:
|
||||||
return [m for m in models if _is_chat_model(m)]
|
return [m for m in models if _is_chat_model(m)]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -824,6 +966,15 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
|||||||
or "ollama" in (parsed_base.hostname or "").lower()
|
or "ollama" in (parsed_base.hostname or "").lower()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _is_loading_model_response(r) -> bool:
|
||||||
|
if getattr(r, "status_code", None) != 503:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
body = r.text or ""
|
||||||
|
except Exception:
|
||||||
|
body = ""
|
||||||
|
return "loading model" in body.lower()
|
||||||
|
|
||||||
def _result_from_response(r) -> Dict[str, Any]:
|
def _result_from_response(r) -> Dict[str, Any]:
|
||||||
if 300 <= r.status_code < 400:
|
if 300 <= r.status_code < 400:
|
||||||
loc = r.headers.get("location", "")
|
loc = r.headers.get("location", "")
|
||||||
@@ -840,6 +991,13 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
|||||||
"status_code": r.status_code,
|
"status_code": r.status_code,
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
if _is_loading_model_response(r):
|
||||||
|
return {
|
||||||
|
"reachable": True,
|
||||||
|
"loading": True,
|
||||||
|
"status_code": r.status_code,
|
||||||
|
"error": "Loading model",
|
||||||
|
}
|
||||||
return {"reachable": False, "status_code": r.status_code, "error": f"HTTP {r.status_code}"}
|
return {"reachable": False, "status_code": r.status_code, "error": f"HTTP {r.status_code}"}
|
||||||
|
|
||||||
last_error: Optional[str] = None
|
last_error: Optional[str] = None
|
||||||
@@ -872,7 +1030,7 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
|||||||
if 400 <= sc < 500 and sc not in (401, 403):
|
if 400 <= sc < 500 and sc not in (401, 403):
|
||||||
models_url = _safe_build_models_url(base)
|
models_url = _safe_build_models_url(base)
|
||||||
try:
|
try:
|
||||||
r2 = httpx.get(models_url, headers=headers, timeout=timeout, verify=llm_verify())
|
r2 = httpx.get(models_url, headers=headers,timeout=timeout, verify=llm_verify())
|
||||||
result2 = _result_from_response(r2)
|
result2 = _result_from_response(r2)
|
||||||
if result2["reachable"]:
|
if result2["reachable"]:
|
||||||
return result2
|
return result2
|
||||||
@@ -994,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.
|
||||||
|
|
||||||
@@ -1007,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))
|
||||||
@@ -1056,9 +1245,11 @@ def setup_model_routes(model_discovery):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
def _failure_delay(fails: int) -> float:
|
def _failure_delay(fails: int, *, empty_local: bool = False) -> float:
|
||||||
if fails <= 0:
|
if fails <= 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
if empty_local:
|
||||||
|
return min(5.0 * (2 ** max(0, fails - 1)), 30.0)
|
||||||
return min(_REFRESH_FAILURE_BASE * (2 ** max(0, fails - 1)), _REFRESH_FAILURE_MAX)
|
return min(_REFRESH_FAILURE_BASE * (2 ** max(0, fails - 1)), _REFRESH_FAILURE_MAX)
|
||||||
|
|
||||||
def _should_refresh_endpoint(ep: Any, now: float, force: bool = False) -> tuple[bool, Dict[str, Any]]:
|
def _should_refresh_endpoint(ep: Any, now: float, force: bool = False) -> tuple[bool, Dict[str, Any]]:
|
||||||
@@ -1089,7 +1280,12 @@ def setup_model_routes(model_discovery):
|
|||||||
fails = int(state.get("fail_count") or 0)
|
fails = int(state.get("fail_count") or 0)
|
||||||
if fails and not force:
|
if fails and not force:
|
||||||
last_failure = float(state.get("last_failure") or 0.0)
|
last_failure = float(state.get("last_failure") or 0.0)
|
||||||
if now - last_failure < _failure_delay(fails):
|
empty_local = (
|
||||||
|
not cached
|
||||||
|
and category == "local"
|
||||||
|
and str(getattr(ep, "id", "") or "").startswith("local-")
|
||||||
|
)
|
||||||
|
if now - last_failure < _failure_delay(fails, empty_local=empty_local):
|
||||||
return False, info
|
return False, info
|
||||||
if cached and not force:
|
if cached and not force:
|
||||||
interval = _endpoint_refresh_interval(ep, category)
|
interval = _endpoint_refresh_interval(ep, category)
|
||||||
@@ -1115,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]] = {}
|
||||||
@@ -1188,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
|
||||||
@@ -1227,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,
|
||||||
@@ -1257,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
|
||||||
@@ -1299,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
|
||||||
|
|
||||||
@@ -1309,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):
|
||||||
@@ -1323,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:
|
||||||
@@ -1343,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
|
||||||
@@ -1362,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,
|
||||||
@@ -1373,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."""
|
||||||
@@ -1404,7 +1622,7 @@ def setup_model_routes(model_discovery):
|
|||||||
t0 = _time.time()
|
t0 = _time.time()
|
||||||
ping = _ping_endpoint(base, ep.api_key, timeout=1.5)
|
ping = _ping_endpoint(base, ep.api_key, timeout=1.5)
|
||||||
entry["latency_ms"] = round((_time.time() - t0) * 1000)
|
entry["latency_ms"] = round((_time.time() - t0) * 1000)
|
||||||
entry["status"] = "online" if ping.get("reachable") or cached_count else "offline"
|
entry["status"] = "loading" if ping.get("loading") else ("online" if ping.get("reachable") or cached_count else "offline")
|
||||||
entry["error"] = ping.get("error")
|
entry["error"] = ping.get("error")
|
||||||
entry["model_count"] = cached_count or (len(ANTHROPIC_MODELS) if provider == "anthropic" else 0)
|
entry["model_count"] = cached_count or (len(ANTHROPIC_MODELS) if provider == "anthropic" else 0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1557,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:
|
||||||
@@ -1564,39 +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:
|
|
||||||
ping = _ping_endpoint(r.base_url, r.api_key, timeout=3.5)
|
|
||||||
if ping.get("reachable"):
|
|
||||||
status = "empty"
|
|
||||||
# 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=5)
|
|
||||||
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({
|
||||||
@@ -1726,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),
|
||||||
@@ -1766,7 +1965,7 @@ def setup_model_routes(model_discovery):
|
|||||||
model_ids = _probe_endpoint(base_url, api_key.strip() or None, timeout=explicit_timeout) if should_probe else []
|
model_ids = _probe_endpoint(base_url, api_key.strip() or None, timeout=explicit_timeout) if should_probe else []
|
||||||
ping = {"reachable": False, "error": None}
|
ping = {"reachable": False, "error": None}
|
||||||
if (should_probe or requested_kind in ("api", "proxy")) and not model_ids:
|
if (should_probe or requested_kind in ("api", "proxy")) and not model_ids:
|
||||||
ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=min(explicit_timeout, 2.0))
|
ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=min(explicit_timeout, 10.0))
|
||||||
if require_model_list and not model_ids:
|
if require_model_list and not model_ids:
|
||||||
raise HTTPException(400, _model_endpoint_error_message(base_url, ping))
|
raise HTTPException(400, _model_endpoint_error_message(base_url, ping))
|
||||||
|
|
||||||
@@ -1813,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 ""
|
||||||
@@ -1833,7 +2043,7 @@ def setup_model_routes(model_discovery):
|
|||||||
"models": _merge_model_ids(model_ids, _pinned),
|
"models": _merge_model_ids(model_ids, _pinned),
|
||||||
"pinned_models": _pinned,
|
"pinned_models": _pinned,
|
||||||
"online": bool(model_ids) or bool(_pinned) or bool(ping.get("reachable")),
|
"online": bool(model_ids) or bool(_pinned) or bool(ping.get("reachable")),
|
||||||
"status": "online" if (model_ids or _pinned) else ("empty" if ping.get("reachable") else "offline"),
|
"status": "online" if (model_ids or _pinned) else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")),
|
||||||
"ping_error": ping.get("error") if ping else None,
|
"ping_error": ping.get("error") if ping else None,
|
||||||
"endpoint_kind": requested_kind,
|
"endpoint_kind": requested_kind,
|
||||||
"category": _classify_endpoint(base_url, requested_kind),
|
"category": _classify_endpoint(base_url, requested_kind),
|
||||||
@@ -1858,11 +2068,11 @@ def setup_model_routes(model_discovery):
|
|||||||
configured_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60)
|
configured_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60)
|
||||||
probe_timeout = _explicit_model_list_timeout(base_url, requested_kind, configured_timeout)
|
probe_timeout = _explicit_model_list_timeout(base_url, requested_kind, configured_timeout)
|
||||||
models = _probe_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout)
|
models = _probe_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout)
|
||||||
ping = {"reachable": True, "error": None} if models else _ping_endpoint(base_url, api_key.strip() or None, timeout=min(probe_timeout, 2.0))
|
ping = {"reachable": True, "error": None} if models else _ping_endpoint(base_url, api_key.strip() or None, timeout=min(probe_timeout, 10.0))
|
||||||
return {
|
return {
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"online": bool(models) or bool(ping.get("reachable")),
|
"online": bool(models) or bool(ping.get("reachable")),
|
||||||
"status": "online" if models else ("empty" if ping.get("reachable") else "offline"),
|
"status": "online" if models else ("loading" if ping.get("loading") else ("empty" if ping.get("reachable") else "offline")),
|
||||||
"ping_error": ping.get("error") if ping else None,
|
"ping_error": ping.get("error") if ping else None,
|
||||||
"models": models,
|
"models": models,
|
||||||
"count": len(models),
|
"count": len(models),
|
||||||
@@ -2040,6 +2250,16 @@ def setup_model_routes(model_discovery):
|
|||||||
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
||||||
model = (_user_prefs.get("default_model") or "").strip()
|
model = (_user_prefs.get("default_model") or "").strip()
|
||||||
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
||||||
|
# If user has no personal default, fall back to global default
|
||||||
|
# But only based on the "share_defaults_with_users" flag
|
||||||
|
# (only if share_defaults_with_users is enabled)
|
||||||
|
if settings.get("share_defaults_with_users", False):
|
||||||
|
if not ep_id:
|
||||||
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
|
if not model:
|
||||||
|
model = settings.get("default_model", "")
|
||||||
|
if not _fallbacks:
|
||||||
|
_fallbacks = settings.get("default_model_fallbacks") or []
|
||||||
else:
|
else:
|
||||||
ep_id = settings.get("default_endpoint_id", "")
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
model = settings.get("default_model", "")
|
model = settings.get("default_model", "")
|
||||||
|
|||||||
+40
-6
@@ -13,6 +13,7 @@ from core.database import SessionLocal, Note
|
|||||||
from core.middleware import INTERNAL_TOOL_USER
|
from core.middleware import INTERNAL_TOOL_USER
|
||||||
from src.auth_helpers import require_user
|
from src.auth_helpers import require_user
|
||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR
|
||||||
|
from src.upload_handler import reserve_upload_references
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -335,10 +336,11 @@ async def dispatch_reminder(
|
|||||||
# Loud diagnostic so we can see WHY a reminder didn't send (the
|
# Loud diagnostic so we can see WHY a reminder didn't send (the
|
||||||
# previous "silently no-op when cfg has no smtp_host" was invisible).
|
# previous "silently no-op when cfg has no smtp_host" was invisible).
|
||||||
logger.info(
|
logger.info(
|
||||||
f"dispatch_reminder[email] note_id={note_id} owner={owner!r} "
|
"dispatch_reminder[email] note_id=%s owner=%r "
|
||||||
f"smtp_host={cfg.get('smtp_host')!r} smtp_user={cfg.get('smtp_user')!r} "
|
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
|
||||||
f"from={from_addr!r} recipient={recipient!r} "
|
note_id, owner,
|
||||||
f"account_name={cfg.get('account_name')!r}"
|
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
|
||||||
|
bool(from_addr), bool(recipient),
|
||||||
)
|
)
|
||||||
missing = []
|
missing = []
|
||||||
if not cfg.get("smtp_host"):
|
if not cfg.get("smtp_host"):
|
||||||
@@ -478,10 +480,23 @@ async def dispatch_reminder(
|
|||||||
base = intg["base_url"].rstrip("/")
|
base = intg["base_url"].rstrip("/")
|
||||||
topic = settings.get("reminder_ntfy_topic") or "reminders"
|
topic = settings.get("reminder_ntfy_topic") or "reminders"
|
||||||
ntfy_body = synthesis or note_body or title
|
ntfy_body = synthesis or note_body or title
|
||||||
hdrs = {"Title": title or "Reminder", "Priority": "high", "Tags": "bell"}
|
# ntfy Title is an ASCII HTTP header; sanitize Unicode and cap its length.
|
||||||
|
_clean_title = (title or "Reminder").encode("ascii", "replace").decode("ascii")[:200]
|
||||||
|
hdrs = {"Title": _clean_title, "Priority": "high", "Tags": "bell"}
|
||||||
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
|
||||||
@@ -560,7 +575,7 @@ async def dispatch_reminder(
|
|||||||
# Router factory
|
# Router factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def setup_note_routes(task_scheduler=None):
|
def setup_note_routes(task_scheduler=None, upload_handler=None):
|
||||||
# Expose the scheduler to module-level `dispatch_reminder` so reminders
|
# Expose the scheduler to module-level `dispatch_reminder` so reminders
|
||||||
# can also push to the in-app notification queue (the polling system
|
# can also push to the in-app notification queue (the polling system
|
||||||
# turns each entry into a real browser Notification + the existing
|
# turns each entry into a real browser Notification + the existing
|
||||||
@@ -582,6 +597,11 @@ def setup_note_routes(task_scheduler=None):
|
|||||||
# did not.
|
# did not.
|
||||||
return require_user(request) or None
|
return require_user(request) or None
|
||||||
|
|
||||||
|
def _reserve_note_uploads(owner: Optional[str], *values) -> None:
|
||||||
|
missing_id = reserve_upload_references(upload_handler, owner, *values)
|
||||||
|
if missing_id:
|
||||||
|
raise HTTPException(409, f"Referenced upload is no longer available: {missing_id}")
|
||||||
|
|
||||||
def _is_admin_or_single_user(request: Request, user: str | None) -> bool:
|
def _is_admin_or_single_user(request: Request, user: str | None) -> bool:
|
||||||
if user == INTERNAL_TOOL_USER:
|
if user == INTERNAL_TOOL_USER:
|
||||||
return True
|
return True
|
||||||
@@ -631,6 +651,13 @@ def setup_note_routes(task_scheduler=None):
|
|||||||
@router.post("")
|
@router.post("")
|
||||||
def create_note(request: Request, body: NoteCreate):
|
def create_note(request: Request, body: NoteCreate):
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
|
_reserve_note_uploads(
|
||||||
|
user,
|
||||||
|
body.image_url,
|
||||||
|
body.color,
|
||||||
|
body.content,
|
||||||
|
json.dumps(body.items) if body.items is not None else None,
|
||||||
|
)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
note = Note(
|
note = Note(
|
||||||
@@ -688,6 +715,13 @@ def setup_note_routes(task_scheduler=None):
|
|||||||
if user is not None and note.owner != user:
|
if user is not None and note.owner != user:
|
||||||
raise HTTPException(404, "Note not found")
|
raise HTTPException(404, "Note not found")
|
||||||
|
|
||||||
|
_reserve_note_uploads(
|
||||||
|
user,
|
||||||
|
body.image_url,
|
||||||
|
body.color,
|
||||||
|
body.content,
|
||||||
|
json.dumps(body.items) if body.items is not None else None,
|
||||||
|
)
|
||||||
if body.title is not None:
|
if body.title is not None:
|
||||||
note.title = body.title
|
note.title = body.title
|
||||||
if body.content is not None:
|
if body.content is not None:
|
||||||
|
|||||||
@@ -358,11 +358,13 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
||||||
|
|
||||||
# Delete file from disk if it's in uploads dir
|
# Delete file from disk if it's in the caller's own uploads dir.
|
||||||
|
# Scope to the per-owner subdir, not the shared uploads root, so one
|
||||||
|
# admin can't delete another user's personal files by path.
|
||||||
deleted_from_disk = False
|
deleted_from_disk = False
|
||||||
try:
|
try:
|
||||||
abs_target = os.path.realpath(filepath)
|
abs_target = os.path.realpath(filepath)
|
||||||
base_abs = os.path.realpath(UPLOADS_DIR)
|
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
|
||||||
in_uploads = (
|
in_uploads = (
|
||||||
abs_target == base_abs
|
abs_target == base_abs
|
||||||
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Preset routes — /api/presets GET, /api/presets/custom POST, user templates CRUD."""
|
"""Preset routes — /api/presets GET, /api/presets/custom POST, user templates CRUD."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
@@ -102,7 +103,7 @@ def setup_preset_routes(preset_manager) -> APIRouter:
|
|||||||
try:
|
try:
|
||||||
model_spec = data.get("model") or ""
|
model_spec = data.get("model") or ""
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
url, model, headers = _resolve_model(model_spec, owner=user)
|
url, model, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=user)
|
||||||
result = await llm_call_async(url, model, messages, temperature=0.8, max_tokens=500, headers=headers)
|
result = await llm_call_async(url, model, messages, temperature=0.8, max_tokens=500, headers=headers)
|
||||||
return {"success": True, "prompt": result.strip()}
|
return {"success": True, "prompt": result.strip()}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Research route domain package (slice 2b, #4082/#4071).
|
||||||
|
|
||||||
|
Contains research_routes.py, migrated from the flat routes/ directory.
|
||||||
|
Backward-compat shim at routes/research_routes.py re-exports from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,783 @@
|
|||||||
|
"""Research background task routes — /api/research/*."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from core.middleware import INTERNAL_TOOL_USER
|
||||||
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
from src.auth_helpers import _auth_disabled, get_current_user
|
||||||
|
from core.auth import RESERVED_USERNAMES
|
||||||
|
from src.constants import DEEP_RESEARCH_DIR
|
||||||
|
|
||||||
|
_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__)
|
||||||
|
|
||||||
|
# Model-name substrings that are NOT chat/generation models — research must
|
||||||
|
# never pick these as its model. An OpenAI-style endpoint often lists
|
||||||
|
# `text-embedding-ada-002` etc. first in its model list, which is why research
|
||||||
|
# was failing with "Cannot reach model 'text-embedding-ada-002'".
|
||||||
|
_NON_CHAT_MODEL = (
|
||||||
|
"text-embedding", "embedding", "tts-", "whisper", "dall-e",
|
||||||
|
"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:
|
||||||
|
"""First model that isn't an embedding/tts/etc. — falls back to models[0]."""
|
||||||
|
for m in (models or []):
|
||||||
|
if not any(p in str(m).lower() for p in _NON_CHAT_MODEL):
|
||||||
|
return m
|
||||||
|
return (models[0] if models else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_research_endpoint(sess, owner: Optional[str] = None) -> tuple:
|
||||||
|
"""Return (endpoint_url, model, headers) for Deep Research, checking admin overrides."""
|
||||||
|
owner = owner or getattr(sess, "owner", None) or None
|
||||||
|
url, model, headers = resolve_endpoint(
|
||||||
|
"research",
|
||||||
|
fallback_url=sess.endpoint_url,
|
||||||
|
fallback_model=sess.model,
|
||||||
|
fallback_headers=sess.headers,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
return url, model, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_enabled_endpoint(db, owner, endpoint_id=None):
|
||||||
|
"""An enabled ModelEndpoint VISIBLE to `owner` (their own rows + legacy
|
||||||
|
null-owner "shared" rows), optionally narrowed to a specific endpoint_id;
|
||||||
|
None if nothing visible matches.
|
||||||
|
|
||||||
|
Owner-scoped on purpose. ModelEndpoint is per-user (core/database.py: non-null
|
||||||
|
owner = private, "the model picker only shows the endpoint to that user") and
|
||||||
|
holds a decrypted `api_key`. /api/research/start feeds the resolved row's
|
||||||
|
api_key + base_url into research_handler.start_research(llm_endpoint=,
|
||||||
|
llm_headers=), so an UNSCOPED lookup — by the caller-supplied endpoint_id, or
|
||||||
|
via the bare first-enabled fallback — would let a research-privileged user
|
||||||
|
spend ANOTHER user's API key/quota and reach whatever internal base_url they
|
||||||
|
configured. Mirrors webhook_routes._first_enabled_endpoint and
|
||||||
|
session_routes._owned_endpoint. A null/empty owner is a no-op (single-user /
|
||||||
|
legacy mode).
|
||||||
|
"""
|
||||||
|
from src.database import ModelEndpoint
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||||
|
if endpoint_id:
|
||||||
|
q = q.filter(ModelEndpoint.id == endpoint_id)
|
||||||
|
return owner_filter(q, ModelEndpoint, owner).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||||
|
"""Resolve a ModelEndpoint row into (chat_url, model, headers).
|
||||||
|
|
||||||
|
Mirrors endpoint_resolver.resolve_endpoint's provider-auth handling for
|
||||||
|
panel-selected research endpoints. ChatGPT Subscription endpoints keep
|
||||||
|
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||||
|
"""
|
||||||
|
from src.endpoint_resolver import (
|
||||||
|
build_chat_url,
|
||||||
|
build_headers,
|
||||||
|
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
base, api_key = resolve_model_endpoint_runtime(ep, owner=owner)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not resolve endpoint credentials for research: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
ep_model = (model or "").strip()
|
||||||
|
if not ep_model:
|
||||||
|
try:
|
||||||
|
models = json.loads(ep.cached_models) if ep.cached_models else []
|
||||||
|
if models:
|
||||||
|
ep_model = _first_chat_model(models)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not ep_model:
|
||||||
|
return None
|
||||||
|
return build_chat_url(base), ep_model, build_headers(api_key, base)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||||
|
router = APIRouter(tags=["research"])
|
||||||
|
|
||||||
|
def _require_user(request: Request) -> str:
|
||||||
|
"""All research endpoints require an authenticated user. Research
|
||||||
|
data isn't owner-scoped in the on-disk JSON yet, so we at least
|
||||||
|
block anonymous access. Multi-tenant deploys should additionally
|
||||||
|
verify the session belongs to this user."""
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
if _auth_disabled():
|
||||||
|
return ""
|
||||||
|
raise HTTPException(401, "Not authenticated")
|
||||||
|
return user
|
||||||
|
|
||||||
|
def _owns_in_memory(session_id: str, user: str) -> bool:
|
||||||
|
"""Ownership check for an in-flight (in-memory) research task.
|
||||||
|
Falls back to the on-disk JSON if the task has already finished."""
|
||||||
|
entry = research_handler._active_tasks.get(session_id)
|
||||||
|
if entry is not None:
|
||||||
|
return entry.get("owner", "") == user
|
||||||
|
# Task no longer in memory — check the persisted JSON.
|
||||||
|
try:
|
||||||
|
return _find_owned_research_path(session_id, user) is not None
|
||||||
|
except HTTPException:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _require_owned_or_active_research_path(session_id: str, user: str) -> Path | None:
|
||||||
|
"""Validate ownership once and return the completed on-disk path.
|
||||||
|
|
||||||
|
Active running research has no completed disk path yet. Completed
|
||||||
|
tasks can remain in _active_tasks after persistence, so prefer their
|
||||||
|
owned disk path when available. Completed disk lookups still reuse the
|
||||||
|
path after the ownership gate.
|
||||||
|
"""
|
||||||
|
entry = research_handler._active_tasks.get(session_id)
|
||||||
|
if entry is not None:
|
||||||
|
if entry.get("owner", "") != user:
|
||||||
|
raise HTTPException(404, "No research found for this session")
|
||||||
|
if entry.get("status") != "running":
|
||||||
|
path = _find_owned_research_path(session_id, user)
|
||||||
|
if path is not None:
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = _find_owned_research_path(session_id, user)
|
||||||
|
if path is None:
|
||||||
|
raise HTTPException(404, "No research found for this session")
|
||||||
|
return path
|
||||||
|
|
||||||
|
@router.get("/api/research/active")
|
||||||
|
async def research_active(request: Request):
|
||||||
|
"""List all currently active (running) research tasks."""
|
||||||
|
user = _require_user(request)
|
||||||
|
active = []
|
||||||
|
for sid, entry in research_handler._active_tasks.items():
|
||||||
|
# SECURITY: only show this user's running tasks.
|
||||||
|
if entry.get("owner", "") != user:
|
||||||
|
continue
|
||||||
|
if entry.get("status") == "running":
|
||||||
|
active.append({
|
||||||
|
"session_id": sid,
|
||||||
|
"query": entry.get("query", ""),
|
||||||
|
"status": "running",
|
||||||
|
"progress": entry.get("progress", {}),
|
||||||
|
"started_at": entry.get("started_at", 0),
|
||||||
|
})
|
||||||
|
return {"active": active}
|
||||||
|
|
||||||
|
@router.get("/api/research/status/{session_id}")
|
||||||
|
async def research_status(session_id: str, request: Request):
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
if not _owns_in_memory(session_id, user):
|
||||||
|
raise HTTPException(404, "No research found for this session")
|
||||||
|
status = research_handler.get_status(session_id)
|
||||||
|
if status is None:
|
||||||
|
raise HTTPException(404, "No research found for this session")
|
||||||
|
return status
|
||||||
|
|
||||||
|
@router.post("/api/research/cancel/{session_id}")
|
||||||
|
async def research_cancel(session_id: str, request: Request):
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
if not _owns_in_memory(session_id, user):
|
||||||
|
raise HTTPException(404, "No research found for this session")
|
||||||
|
cancelled = research_handler.cancel_research(session_id)
|
||||||
|
return {"cancelled": cancelled}
|
||||||
|
|
||||||
|
@router.post("/api/research/result/{session_id}")
|
||||||
|
async def research_result(session_id: str, request: Request):
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
if not _owns_in_memory(session_id, user):
|
||||||
|
raise HTTPException(404, "No research result available")
|
||||||
|
result = research_handler.get_result(session_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, "No research result available")
|
||||||
|
sources = research_handler.get_sources(session_id) or []
|
||||||
|
raw_findings = research_handler.get_raw_findings(session_id) or []
|
||||||
|
research_handler.clear_result(session_id)
|
||||||
|
return {"result": result, "sources": sources, "raw_findings": raw_findings}
|
||||||
|
|
||||||
|
def _assert_owns_research(session_id: str, user: str) -> None:
|
||||||
|
"""404-not-403 ownership gate for a research session's on-disk JSON.
|
||||||
|
Use BEFORE returning any data or mutating the file."""
|
||||||
|
path = _require_research_path(session_id)
|
||||||
|
try:
|
||||||
|
owner = json.loads(path.read_text(encoding="utf-8")).get("owner")
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(404, "Research not found")
|
||||||
|
if owner != user:
|
||||||
|
raise HTTPException(404, "Research not found")
|
||||||
|
|
||||||
|
@router.get("/api/research/report/{session_id}")
|
||||||
|
async def research_report(session_id: str, request: Request):
|
||||||
|
"""Serve the visual HTML report for a completed research session."""
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
_assert_owns_research(session_id, user)
|
||||||
|
logger.info(f"Visual report requested for session {session_id}")
|
||||||
|
try:
|
||||||
|
html_content = research_handler.get_report_html(session_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Visual report generation error: {e}", exc_info=True)
|
||||||
|
raise HTTPException(500, f"Report generation failed: {e}")
|
||||||
|
if html_content is None:
|
||||||
|
logger.warning(f"No report data found for session {session_id}")
|
||||||
|
raise HTTPException(404, "No visual report available for this session")
|
||||||
|
return HTMLResponse(content=html_content)
|
||||||
|
|
||||||
|
class HideImageRequest(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
@router.post("/api/research/{session_id}/hide-image")
|
||||||
|
async def research_hide_image(session_id: str, body: HideImageRequest, request: Request):
|
||||||
|
"""Mark an image URL as hidden for this research's visual report.
|
||||||
|
Persisted to the research JSON so subsequent /report renders skip it."""
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
_assert_owns_research(session_id, user)
|
||||||
|
ok = research_handler.hide_image(session_id, body.url)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(404, "Research not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.post("/api/research/{session_id}/unhide-images")
|
||||||
|
async def research_unhide_images(session_id: str, request: Request):
|
||||||
|
"""Clear the hidden-images list for a research session."""
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
_assert_owns_research(session_id, user)
|
||||||
|
ok = research_handler.unhide_all_images(session_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(404, "Research not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.get("/api/research/library")
|
||||||
|
async def research_library(
|
||||||
|
request: Request,
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
sort: str = Query("recent"),
|
||||||
|
limit: int = Query(50),
|
||||||
|
archived: bool = Query(False),
|
||||||
|
):
|
||||||
|
user = _require_user(request)
|
||||||
|
"""List all completed research for the Library panel."""
|
||||||
|
data_dir = Path(DEEP_RESEARCH_DIR)
|
||||||
|
items = []
|
||||||
|
for p in data_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
d = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
# SECURITY: only show research belonging to this user. Legacy
|
||||||
|
# JSONs without an `owner` field are hidden — auth was the only
|
||||||
|
# gate before, so every user saw every other user's reports.
|
||||||
|
if d.get("owner") != user:
|
||||||
|
continue
|
||||||
|
# Archived view shows ONLY archived reports; default hides them.
|
||||||
|
if bool(d.get("archived")) != archived:
|
||||||
|
continue
|
||||||
|
query = d.get("query", "")
|
||||||
|
if search and search.lower() not in query.lower():
|
||||||
|
continue
|
||||||
|
sources = d.get("sources", [])
|
||||||
|
items.append({
|
||||||
|
"id": p.stem,
|
||||||
|
"query": query,
|
||||||
|
"category": d.get("category") or "",
|
||||||
|
"source_count": len(sources),
|
||||||
|
"status": d.get("status", "done"),
|
||||||
|
"duration": d.get("stats", {}).get("Duration", ""),
|
||||||
|
"rounds": d.get("stats", {}).get("Rounds", ""),
|
||||||
|
"started_at": d.get("started_at", 0),
|
||||||
|
"completed_at": d.get("completed_at", 0),
|
||||||
|
"archived": bool(d.get("archived")),
|
||||||
|
"thumbnail": _research_thumbnail(d),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
if sort == "recent":
|
||||||
|
items.sort(key=lambda x: x["completed_at"] or 0, reverse=True)
|
||||||
|
elif sort == "oldest":
|
||||||
|
items.sort(key=lambda x: x["completed_at"] or 0)
|
||||||
|
elif sort == "most-messages":
|
||||||
|
items.sort(key=lambda x: x["source_count"], reverse=True)
|
||||||
|
elif sort == "alpha":
|
||||||
|
items.sort(key=lambda x: x["query"].lower())
|
||||||
|
|
||||||
|
return {"research": items[:limit], "total": len(items)}
|
||||||
|
|
||||||
|
@router.get("/api/research/detail/{session_id}")
|
||||||
|
async def research_detail(session_id: str, request: Request):
|
||||||
|
"""Return the full JSON for a single research result — sources,
|
||||||
|
summary, stats — used by the Library preview panel."""
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
path = _require_research_path(session_id)
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Failed to read research: {e}")
|
||||||
|
# SECURITY: 404 (not 403) so we don't leak that the report exists.
|
||||||
|
if data.get("owner") != user:
|
||||||
|
raise HTTPException(404, "Research not found")
|
||||||
|
return data
|
||||||
|
|
||||||
|
@router.post("/api/research/{session_id}/archive")
|
||||||
|
async def research_archive(session_id: str, request: Request, archived: bool = Query(True)):
|
||||||
|
"""Soft-archive / restore a research report (sets `archived` in its JSON)."""
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
path = _require_research_path(session_id)
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if data.get("owner") != user:
|
||||||
|
raise HTTPException(404, "Research not found")
|
||||||
|
data["archived"] = bool(archived)
|
||||||
|
path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Failed to update research: {e}")
|
||||||
|
return {"ok": True, "id": session_id, "archived": bool(archived)}
|
||||||
|
|
||||||
|
@router.delete("/api/research/{session_id}")
|
||||||
|
async def research_delete(session_id: str, request: Request):
|
||||||
|
"""Delete a research result from disk."""
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
json_path = _find_research_path(session_id)
|
||||||
|
deleted = False
|
||||||
|
if json_path is not None:
|
||||||
|
# SECURITY: verify ownership before letting the caller delete it.
|
||||||
|
try:
|
||||||
|
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||||
|
if data.get("owner") != user:
|
||||||
|
raise HTTPException(404, "Research not found")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(404, "Research not found")
|
||||||
|
json_path.unlink()
|
||||||
|
deleted = True
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Panel endpoints — launch research without a chat session
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ResearchStartRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
# max_rounds=0 means "Auto" — let the AI decide when to stop, capped at 20.
|
||||||
|
max_rounds: int = Field(default=0, ge=0, le=20)
|
||||||
|
search_provider: Optional[str] = None
|
||||||
|
endpoint_id: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
max_time: int = Field(default=300, ge=60, le=1800)
|
||||||
|
extraction_timeout: Optional[int] = Field(default=None, ge=15, le=3600)
|
||||||
|
extraction_concurrency: Optional[int] = Field(default=None, ge=1, le=12)
|
||||||
|
category: Optional[str] = None
|
||||||
|
|
||||||
|
@router.post("/api/research/start")
|
||||||
|
async def research_start(body: ResearchStartRequest, request: Request):
|
||||||
|
"""Launch a research job from the dedicated panel."""
|
||||||
|
from src.auth_helpers import require_privilege
|
||||||
|
user = require_privilege(request, "can_use_research")
|
||||||
|
if user == INTERNAL_TOOL_USER:
|
||||||
|
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
||||||
|
if tool_owner and tool_owner not in RESERVED_USERNAMES:
|
||||||
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
|
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||||
|
try:
|
||||||
|
privs = auth_mgr.get_privileges(tool_owner) or {}
|
||||||
|
if not privs.get("can_use_research", True):
|
||||||
|
raise HTTPException(403, f"Your account is not allowed to can use research.")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
user = tool_owner
|
||||||
|
session_id = f"rp-{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
if body.endpoint_id:
|
||||||
|
from src.database import SessionLocal
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Owner-scoped: never resolve another user's private endpoint
|
||||||
|
# (and its decrypted api_key / internal base_url). A scoped miss
|
||||||
|
# reads as 404 so the endpoint's existence isn't revealed.
|
||||||
|
ep = _owned_enabled_endpoint(db, user, body.endpoint_id)
|
||||||
|
if not ep:
|
||||||
|
raise HTTPException(404, "Endpoint not found or disabled")
|
||||||
|
resolved = _resolve_endpoint_runtime(ep, owner=user, model=body.model)
|
||||||
|
if not resolved:
|
||||||
|
raise HTTPException(400, "Endpoint is not configured with a usable model.")
|
||||||
|
ep_url, ep_model, ep_headers = resolved
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
else:
|
||||||
|
ep_url, ep_model, ep_headers = resolve_endpoint("research", owner=user)
|
||||||
|
if not ep_url:
|
||||||
|
ep_url, ep_model, ep_headers = resolve_endpoint("utility", owner=user)
|
||||||
|
# When neither research nor utility is configured, use the user's
|
||||||
|
# configured DEFAULT model (default_endpoint_id/default_model) rather
|
||||||
|
# than arbitrarily grabbing the first enabled endpoint's first model
|
||||||
|
# (which surfaced gpt-3.5). "Default" should mean the default model.
|
||||||
|
if not ep_url:
|
||||||
|
ep_url, ep_model, ep_headers = resolve_endpoint("default", owner=user)
|
||||||
|
if not ep_url:
|
||||||
|
ep_url, ep_model, ep_headers = resolve_endpoint("chat", owner=user)
|
||||||
|
if not ep_url:
|
||||||
|
from src.database import SessionLocal
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Owner-scoped first-enabled fallback: the caller's own rows
|
||||||
|
# + legacy null-owner shared rows only — never borrow another
|
||||||
|
# user's private endpoint/api_key. Same fix as the
|
||||||
|
# /api/v1/chat fallback (webhook_routes._first_enabled_endpoint).
|
||||||
|
ep = _owned_enabled_endpoint(db, user)
|
||||||
|
if ep:
|
||||||
|
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||||
|
if resolved:
|
||||||
|
ep_url, ep_model, ep_headers = resolved
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if not ep_url:
|
||||||
|
raise HTTPException(400, "No endpoints configured. Add one in Settings first.")
|
||||||
|
if body.model:
|
||||||
|
ep_model = body.model
|
||||||
|
|
||||||
|
# max_rounds=0 → "Auto", let AI decide; pass 20 as the safety cap.
|
||||||
|
effective_max_rounds = body.max_rounds if body.max_rounds > 0 else 20
|
||||||
|
research_handler.start_research(
|
||||||
|
session_id=session_id,
|
||||||
|
query=body.query,
|
||||||
|
llm_endpoint=ep_url,
|
||||||
|
llm_model=ep_model,
|
||||||
|
max_time=body.max_time,
|
||||||
|
llm_headers=ep_headers,
|
||||||
|
max_rounds=effective_max_rounds,
|
||||||
|
search_provider=body.search_provider or None,
|
||||||
|
category=body.category or None,
|
||||||
|
extraction_timeout=body.extraction_timeout,
|
||||||
|
extraction_concurrency=body.extraction_concurrency,
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
return {"session_id": session_id, "status": "running", "query": body.query}
|
||||||
|
|
||||||
|
@router.get("/api/research/stream/{session_id}")
|
||||||
|
async def research_stream(session_id: str, request: Request):
|
||||||
|
"""SSE stream of research progress events."""
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
if not _owns_in_memory(session_id, user):
|
||||||
|
raise HTTPException(404, "No research found for this session")
|
||||||
|
async def _generate():
|
||||||
|
last_progress = None
|
||||||
|
while True:
|
||||||
|
status = research_handler.get_status(session_id)
|
||||||
|
if status is None:
|
||||||
|
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||||
|
return
|
||||||
|
st = status.get("status", "")
|
||||||
|
progress = status.get("progress", {})
|
||||||
|
if progress != last_progress:
|
||||||
|
last_progress = progress
|
||||||
|
yield f"data: {json.dumps({**progress, 'status': st})}\n\n"
|
||||||
|
if st != "running":
|
||||||
|
final = {'status': st, 'final': True}
|
||||||
|
task = research_handler._active_tasks.get(session_id, {})
|
||||||
|
if st == "error" and task.get("result"):
|
||||||
|
final['error'] = str(task["result"])[:500]
|
||||||
|
yield f"data: {json.dumps(final)}\n\n"
|
||||||
|
return
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_generate(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/api/research/result-peek/{session_id}")
|
||||||
|
async def research_result_peek(session_id: str, request: Request):
|
||||||
|
"""Get research result without clearing it (for panel use)."""
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
owned_disk_path = _require_owned_or_active_research_path(session_id, user)
|
||||||
|
result = research_handler.get_result(session_id)
|
||||||
|
if result is None:
|
||||||
|
p = owned_disk_path
|
||||||
|
if p is not None:
|
||||||
|
d = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
return {
|
||||||
|
"result": d.get("result", ""),
|
||||||
|
"sources": d.get("sources", []),
|
||||||
|
"raw_findings": d.get("raw_findings", []),
|
||||||
|
"category": d.get("category") or "",
|
||||||
|
}
|
||||||
|
raise HTTPException(404, "No research result available")
|
||||||
|
sources = research_handler.get_sources(session_id) or []
|
||||||
|
raw_findings = research_handler.get_raw_findings(session_id) or []
|
||||||
|
return {"result": result, "sources": sources, "raw_findings": raw_findings, "category": ""}
|
||||||
|
|
||||||
|
@router.post("/api/research/spinoff/{session_id}")
|
||||||
|
async def research_spinoff(session_id: str, request: Request):
|
||||||
|
"""Create a new chat session pre-seeded with this research as context.
|
||||||
|
|
||||||
|
Reads the persisted research result + sources for `session_id`, creates
|
||||||
|
a fresh session (inheriting endpoint/model/headers from the source
|
||||||
|
session if available, otherwise from the resolved chat endpoint), and
|
||||||
|
injects a single system message containing the report and sources so
|
||||||
|
the user can ask follow-up questions in a clean conversation.
|
||||||
|
"""
|
||||||
|
user = _require_user(request)
|
||||||
|
_validate_session_id(session_id)
|
||||||
|
# SECURITY: gate on ownership before reading the persisted research —
|
||||||
|
# otherwise any authenticated user could spin off (and thereby read)
|
||||||
|
# another user's report by guessing its session ID. Mirrors every other
|
||||||
|
# endpoint in this file (see result_peek above).
|
||||||
|
owned_disk_path = _require_owned_or_active_research_path(session_id, user)
|
||||||
|
if session_manager is None:
|
||||||
|
raise HTTPException(500, "session_manager not configured")
|
||||||
|
|
||||||
|
# Load research data — prefer in-memory result, fall back to disk
|
||||||
|
result = research_handler.get_result(session_id)
|
||||||
|
sources = research_handler.get_sources(session_id) or []
|
||||||
|
query = ""
|
||||||
|
|
||||||
|
path = owned_disk_path
|
||||||
|
if path is not None:
|
||||||
|
try:
|
||||||
|
disk = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not result:
|
||||||
|
result = disk.get("result")
|
||||||
|
if not sources:
|
||||||
|
sources = disk.get("sources", []) or []
|
||||||
|
query = disk.get("query", "") or ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not read research JSON for spinoff: {e}")
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(404, "No research result available for this session")
|
||||||
|
|
||||||
|
# Inherit endpoint/model/headers from the source session when possible.
|
||||||
|
# For panel-launched research (rp-* IDs), there is no chat session, so
|
||||||
|
# fall back through the same chain as /api/research/start: research →
|
||||||
|
# utility → first enabled endpoint in the DB.
|
||||||
|
ep_url, ep_model, ep_headers = "", "", {}
|
||||||
|
try:
|
||||||
|
src_sess = session_manager.get_session(session_id)
|
||||||
|
ep_url = src_sess.endpoint_url or ""
|
||||||
|
ep_model = src_sess.model or ""
|
||||||
|
ep_headers = dict(src_sess.headers or {})
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _merge(r_url, r_model, r_headers):
|
||||||
|
nonlocal ep_url, ep_model, ep_headers
|
||||||
|
if not ep_url and r_url:
|
||||||
|
ep_url = r_url
|
||||||
|
if not ep_model and r_model:
|
||||||
|
ep_model = r_model
|
||||||
|
if not ep_headers and r_headers:
|
||||||
|
ep_headers = dict(r_headers)
|
||||||
|
|
||||||
|
if not ep_url or not ep_model:
|
||||||
|
_merge(*resolve_endpoint("chat", owner=user))
|
||||||
|
if not ep_url or not ep_model:
|
||||||
|
_merge(*resolve_endpoint("research", owner=user))
|
||||||
|
if not ep_url or not ep_model:
|
||||||
|
_merge(*resolve_endpoint("utility", owner=user))
|
||||||
|
if not ep_url or not ep_model:
|
||||||
|
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ep = _owned_enabled_endpoint(db, user)
|
||||||
|
if ep:
|
||||||
|
base = normalize_base(ep.base_url)
|
||||||
|
fallback_url = build_chat_url(base)
|
||||||
|
fallback_headers = build_headers(ep.api_key, base)
|
||||||
|
fallback_model = ""
|
||||||
|
if ep.cached_models:
|
||||||
|
try:
|
||||||
|
models = json.loads(ep.cached_models)
|
||||||
|
if models:
|
||||||
|
fallback_model = _first_chat_model(models)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_merge(fallback_url, fallback_model, fallback_headers)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if not ep_url or not ep_model:
|
||||||
|
raise HTTPException(400, "No endpoint configured — add one in Settings first")
|
||||||
|
|
||||||
|
# Create new session
|
||||||
|
new_sid = str(uuid.uuid4())
|
||||||
|
|
||||||
|
title_query = (query or "research").strip()
|
||||||
|
if len(title_query) > 60:
|
||||||
|
title_query = title_query[:57] + "…"
|
||||||
|
new_name = f"Follow-up: {title_query}"
|
||||||
|
|
||||||
|
new_sess = session_manager.create_session(
|
||||||
|
session_id=new_sid,
|
||||||
|
name=new_name,
|
||||||
|
endpoint_url=ep_url,
|
||||||
|
model=ep_model,
|
||||||
|
rag=False,
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
if ep_headers:
|
||||||
|
new_sess.headers = ep_headers
|
||||||
|
session_manager.save_sessions()
|
||||||
|
try:
|
||||||
|
from src.event_bus import fire_event
|
||||||
|
fire_event("session_created", user)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||||
|
|
||||||
|
# Build the priming system message — report only, no sources injected.
|
||||||
|
# The user can open the visual report for source details; keeping sources
|
||||||
|
# out of the chat context saves tokens and avoids the AI fabricating
|
||||||
|
# citations.
|
||||||
|
date_str = datetime.utcnow().strftime("%Y-%m-%d")
|
||||||
|
primer = (
|
||||||
|
f"[Research context — {date_str}]\n\n"
|
||||||
|
f"The user previously ran a deep research investigation. Use the "
|
||||||
|
f"report below as your primary knowledge base when answering "
|
||||||
|
f"follow-up questions. If the user asks something not covered, "
|
||||||
|
f"say so plainly rather than guessing.\n\n"
|
||||||
|
f"=== ORIGINAL QUERY ===\n{query or '(not recorded)'}\n\n"
|
||||||
|
f"=== REPORT ===\n{result}"
|
||||||
|
)
|
||||||
|
|
||||||
|
from core.models import ChatMessage
|
||||||
|
new_sess.add_message(ChatMessage(
|
||||||
|
role="system",
|
||||||
|
content=primer,
|
||||||
|
metadata={"research_spinoff_from": session_id},
|
||||||
|
))
|
||||||
|
session_manager.save_sessions()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": new_sid,
|
||||||
|
"name": new_name,
|
||||||
|
"source_count": len(sources),
|
||||||
|
}
|
||||||
|
|
||||||
|
return router
|
||||||
+13
-674
@@ -1,678 +1,17 @@
|
|||||||
"""Research background task routes — /api/research/*."""
|
"""Backward-compat shim — canonical location is routes/research/research_routes.py.
|
||||||
|
|
||||||
import asyncio
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
import json
|
that ``import routes.research_routes``, ``from routes.research_routes import X``,
|
||||||
import logging
|
``importlib.import_module("routes.research_routes")``, and
|
||||||
import re
|
``monkeypatch.setattr("routes.research_routes.ATTR", ...)`` (string-targeted
|
||||||
import uuid
|
patch used by ``test_research_owner_scope_routes.py``) all operate on the
|
||||||
from datetime import datetime
|
*same* object the application actually uses. Keeps existing import paths
|
||||||
from pathlib import Path
|
working after slice 2b (#4082/#4071). Source-introspection tests read the
|
||||||
from typing import Optional
|
canonical file by path.
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
import sys as _sys
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from core.middleware import INTERNAL_TOOL_USER
|
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
|
||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
|
||||||
from core.auth import RESERVED_USERNAMES
|
|
||||||
from src.constants import DEEP_RESEARCH_DIR
|
|
||||||
|
|
||||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
from routes.research import research_routes as _canonical # noqa: F401
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
_sys.modules[__name__] = _canonical
|
||||||
|
|
||||||
# Model-name substrings that are NOT chat/generation models — research must
|
|
||||||
# never pick these as its model. An OpenAI-style endpoint often lists
|
|
||||||
# `text-embedding-ada-002` etc. first in its model list, which is why research
|
|
||||||
# was failing with "Cannot reach model 'text-embedding-ada-002'".
|
|
||||||
_NON_CHAT_MODEL = (
|
|
||||||
"text-embedding", "embedding", "tts-", "whisper", "dall-e",
|
|
||||||
"moderation", "rerank", "reranker", "clip", "stable-diffusion",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _first_chat_model(models) -> str:
|
|
||||||
"""First model that isn't an embedding/tts/etc. — falls back to models[0]."""
|
|
||||||
for m in (models or []):
|
|
||||||
if not any(p in str(m).lower() for p in _NON_CHAT_MODEL):
|
|
||||||
return m
|
|
||||||
return (models[0] if models else "")
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_research_endpoint(sess, owner: Optional[str] = None) -> tuple:
|
|
||||||
"""Return (endpoint_url, model, headers) for Deep Research, checking admin overrides."""
|
|
||||||
owner = owner or getattr(sess, "owner", None) or None
|
|
||||||
url, model, headers = resolve_endpoint(
|
|
||||||
"research",
|
|
||||||
fallback_url=sess.endpoint_url,
|
|
||||||
fallback_model=sess.model,
|
|
||||||
fallback_headers=sess.headers,
|
|
||||||
owner=owner,
|
|
||||||
)
|
|
||||||
return url, model, headers
|
|
||||||
|
|
||||||
|
|
||||||
def _owned_enabled_endpoint(db, owner, endpoint_id=None):
|
|
||||||
"""An enabled ModelEndpoint VISIBLE to `owner` (their own rows + legacy
|
|
||||||
null-owner "shared" rows), optionally narrowed to a specific endpoint_id;
|
|
||||||
None if nothing visible matches.
|
|
||||||
|
|
||||||
Owner-scoped on purpose. ModelEndpoint is per-user (core/database.py: non-null
|
|
||||||
owner = private, "the model picker only shows the endpoint to that user") and
|
|
||||||
holds a decrypted `api_key`. /api/research/start feeds the resolved row's
|
|
||||||
api_key + base_url into research_handler.start_research(llm_endpoint=,
|
|
||||||
llm_headers=), so an UNSCOPED lookup — by the caller-supplied endpoint_id, or
|
|
||||||
via the bare first-enabled fallback — would let a research-privileged user
|
|
||||||
spend ANOTHER user's API key/quota and reach whatever internal base_url they
|
|
||||||
configured. Mirrors webhook_routes._first_enabled_endpoint and
|
|
||||||
session_routes._owned_endpoint. A null/empty owner is a no-op (single-user /
|
|
||||||
legacy mode).
|
|
||||||
"""
|
|
||||||
from src.database import ModelEndpoint
|
|
||||||
from src.auth_helpers import owner_filter
|
|
||||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
|
||||||
if endpoint_id:
|
|
||||||
q = q.filter(ModelEndpoint.id == endpoint_id)
|
|
||||||
return owner_filter(q, ModelEndpoint, owner).first()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
|
||||||
"""Resolve a ModelEndpoint row into (chat_url, model, headers).
|
|
||||||
|
|
||||||
Mirrors endpoint_resolver.resolve_endpoint's provider-auth handling for
|
|
||||||
panel-selected research endpoints. ChatGPT Subscription endpoints keep
|
|
||||||
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
|
||||||
"""
|
|
||||||
from src.endpoint_resolver import (
|
|
||||||
build_chat_url,
|
|
||||||
build_headers,
|
|
||||||
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
base, api_key = resolve_model_endpoint_runtime(ep, owner=owner)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Could not resolve endpoint credentials for research: %s", e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
ep_model = (model or "").strip()
|
|
||||||
if not ep_model:
|
|
||||||
try:
|
|
||||||
models = json.loads(ep.cached_models) if ep.cached_models else []
|
|
||||||
if models:
|
|
||||||
ep_model = _first_chat_model(models)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not ep_model:
|
|
||||||
return None
|
|
||||||
return build_chat_url(base), ep_model, build_headers(api_key, base)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|
||||||
router = APIRouter(tags=["research"])
|
|
||||||
|
|
||||||
def _require_user(request: Request) -> str:
|
|
||||||
"""All research endpoints require an authenticated user. Research
|
|
||||||
data isn't owner-scoped in the on-disk JSON yet, so we at least
|
|
||||||
block anonymous access. Multi-tenant deploys should additionally
|
|
||||||
verify the session belongs to this user."""
|
|
||||||
user = get_current_user(request)
|
|
||||||
if not user:
|
|
||||||
if _auth_disabled():
|
|
||||||
return ""
|
|
||||||
raise HTTPException(401, "Not authenticated")
|
|
||||||
return user
|
|
||||||
|
|
||||||
def _validate_session_id(session_id: str) -> None:
|
|
||||||
if not _SESSION_ID_RE.fullmatch(session_id):
|
|
||||||
raise HTTPException(400, "Invalid session ID format")
|
|
||||||
|
|
||||||
def _owns_in_memory(session_id: str, user: str) -> bool:
|
|
||||||
"""Ownership check for an in-flight (in-memory) research task.
|
|
||||||
Falls back to the on-disk JSON if the task has already finished."""
|
|
||||||
entry = research_handler._active_tasks.get(session_id)
|
|
||||||
if entry is not None:
|
|
||||||
return entry.get("owner", "") == user
|
|
||||||
# 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:
|
|
||||||
return json.loads(path.read_text(encoding="utf-8")).get("owner") == user
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@router.get("/api/research/active")
|
|
||||||
async def research_active(request: Request):
|
|
||||||
"""List all currently active (running) research tasks."""
|
|
||||||
user = _require_user(request)
|
|
||||||
active = []
|
|
||||||
for sid, entry in research_handler._active_tasks.items():
|
|
||||||
# SECURITY: only show this user's running tasks.
|
|
||||||
if entry.get("owner", "") != user:
|
|
||||||
continue
|
|
||||||
if entry.get("status") == "running":
|
|
||||||
active.append({
|
|
||||||
"session_id": sid,
|
|
||||||
"query": entry.get("query", ""),
|
|
||||||
"status": "running",
|
|
||||||
"progress": entry.get("progress", {}),
|
|
||||||
"started_at": entry.get("started_at", 0),
|
|
||||||
})
|
|
||||||
return {"active": active}
|
|
||||||
|
|
||||||
@router.get("/api/research/status/{session_id}")
|
|
||||||
async def research_status(session_id: str, request: Request):
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
if not _owns_in_memory(session_id, user):
|
|
||||||
raise HTTPException(404, "No research found for this session")
|
|
||||||
status = research_handler.get_status(session_id)
|
|
||||||
if status is None:
|
|
||||||
raise HTTPException(404, "No research found for this session")
|
|
||||||
return status
|
|
||||||
|
|
||||||
@router.post("/api/research/cancel/{session_id}")
|
|
||||||
async def research_cancel(session_id: str, request: Request):
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
if not _owns_in_memory(session_id, user):
|
|
||||||
raise HTTPException(404, "No research found for this session")
|
|
||||||
cancelled = research_handler.cancel_research(session_id)
|
|
||||||
return {"cancelled": cancelled}
|
|
||||||
|
|
||||||
@router.post("/api/research/result/{session_id}")
|
|
||||||
async def research_result(session_id: str, request: Request):
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
if not _owns_in_memory(session_id, user):
|
|
||||||
raise HTTPException(404, "No research result available")
|
|
||||||
result = research_handler.get_result(session_id)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(404, "No research result available")
|
|
||||||
sources = research_handler.get_sources(session_id) or []
|
|
||||||
raw_findings = research_handler.get_raw_findings(session_id) or []
|
|
||||||
research_handler.clear_result(session_id)
|
|
||||||
return {"result": result, "sources": sources, "raw_findings": raw_findings}
|
|
||||||
|
|
||||||
def _assert_owns_research(session_id: str, user: str) -> None:
|
|
||||||
"""404-not-403 ownership gate for a research session's on-disk JSON.
|
|
||||||
Use BEFORE returning any data or mutating the file."""
|
|
||||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
|
||||||
if not path.exists():
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
try:
|
|
||||||
owner = json.loads(path.read_text(encoding="utf-8")).get("owner")
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
if owner != user:
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
|
|
||||||
@router.get("/api/research/report/{session_id}")
|
|
||||||
async def research_report(session_id: str, request: Request):
|
|
||||||
"""Serve the visual HTML report for a completed research session."""
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
_assert_owns_research(session_id, user)
|
|
||||||
logger.info(f"Visual report requested for session {session_id}")
|
|
||||||
try:
|
|
||||||
html_content = research_handler.get_report_html(session_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Visual report generation error: {e}", exc_info=True)
|
|
||||||
raise HTTPException(500, f"Report generation failed: {e}")
|
|
||||||
if html_content is None:
|
|
||||||
logger.warning(f"No report data found for session {session_id}")
|
|
||||||
raise HTTPException(404, "No visual report available for this session")
|
|
||||||
return HTMLResponse(content=html_content)
|
|
||||||
|
|
||||||
class HideImageRequest(BaseModel):
|
|
||||||
url: str
|
|
||||||
|
|
||||||
@router.post("/api/research/{session_id}/hide-image")
|
|
||||||
async def research_hide_image(session_id: str, body: HideImageRequest, request: Request):
|
|
||||||
"""Mark an image URL as hidden for this research's visual report.
|
|
||||||
Persisted to the research JSON so subsequent /report renders skip it."""
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
_assert_owns_research(session_id, user)
|
|
||||||
ok = research_handler.hide_image(session_id, body.url)
|
|
||||||
if not ok:
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@router.post("/api/research/{session_id}/unhide-images")
|
|
||||||
async def research_unhide_images(session_id: str, request: Request):
|
|
||||||
"""Clear the hidden-images list for a research session."""
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
_assert_owns_research(session_id, user)
|
|
||||||
ok = research_handler.unhide_all_images(session_id)
|
|
||||||
if not ok:
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@router.get("/api/research/library")
|
|
||||||
async def research_library(
|
|
||||||
request: Request,
|
|
||||||
search: Optional[str] = Query(None),
|
|
||||||
sort: str = Query("recent"),
|
|
||||||
limit: int = Query(50),
|
|
||||||
archived: bool = Query(False),
|
|
||||||
):
|
|
||||||
user = _require_user(request)
|
|
||||||
"""List all completed research for the Library panel."""
|
|
||||||
data_dir = Path(DEEP_RESEARCH_DIR)
|
|
||||||
items = []
|
|
||||||
for p in data_dir.glob("*.json"):
|
|
||||||
try:
|
|
||||||
d = json.loads(p.read_text(encoding="utf-8"))
|
|
||||||
# SECURITY: only show research belonging to this user. Legacy
|
|
||||||
# JSONs without an `owner` field are hidden — auth was the only
|
|
||||||
# gate before, so every user saw every other user's reports.
|
|
||||||
if d.get("owner") != user:
|
|
||||||
continue
|
|
||||||
# Archived view shows ONLY archived reports; default hides them.
|
|
||||||
if bool(d.get("archived")) != archived:
|
|
||||||
continue
|
|
||||||
query = d.get("query", "")
|
|
||||||
if search and search.lower() not in query.lower():
|
|
||||||
continue
|
|
||||||
sources = d.get("sources", [])
|
|
||||||
items.append({
|
|
||||||
"id": p.stem,
|
|
||||||
"query": query,
|
|
||||||
"category": d.get("category") or "",
|
|
||||||
"source_count": len(sources),
|
|
||||||
"status": d.get("status", "done"),
|
|
||||||
"duration": d.get("stats", {}).get("Duration", ""),
|
|
||||||
"rounds": d.get("stats", {}).get("Rounds", ""),
|
|
||||||
"started_at": d.get("started_at", 0),
|
|
||||||
"completed_at": d.get("completed_at", 0),
|
|
||||||
"archived": bool(d.get("archived")),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sort
|
|
||||||
if sort == "recent":
|
|
||||||
items.sort(key=lambda x: x["completed_at"] or 0, reverse=True)
|
|
||||||
elif sort == "oldest":
|
|
||||||
items.sort(key=lambda x: x["completed_at"] or 0)
|
|
||||||
elif sort == "most-messages":
|
|
||||||
items.sort(key=lambda x: x["source_count"], reverse=True)
|
|
||||||
elif sort == "alpha":
|
|
||||||
items.sort(key=lambda x: x["query"].lower())
|
|
||||||
|
|
||||||
return {"research": items[:limit], "total": len(items)}
|
|
||||||
|
|
||||||
@router.get("/api/research/detail/{session_id}")
|
|
||||||
async def research_detail(session_id: str, request: Request):
|
|
||||||
"""Return the full JSON for a single research result — sources,
|
|
||||||
summary, stats — used by the Library preview panel."""
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
|
||||||
if not path.exists():
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, f"Failed to read research: {e}")
|
|
||||||
# SECURITY: 404 (not 403) so we don't leak that the report exists.
|
|
||||||
if data.get("owner") != user:
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
return data
|
|
||||||
|
|
||||||
@router.post("/api/research/{session_id}/archive")
|
|
||||||
async def research_archive(session_id: str, request: Request, archived: bool = Query(True)):
|
|
||||||
"""Soft-archive / restore a research report (sets `archived` in its JSON)."""
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
|
||||||
if not path.exists():
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
if data.get("owner") != user:
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
data["archived"] = bool(archived)
|
|
||||||
path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, f"Failed to update research: {e}")
|
|
||||||
return {"ok": True, "id": session_id, "archived": bool(archived)}
|
|
||||||
|
|
||||||
@router.delete("/api/research/{session_id}")
|
|
||||||
async def research_delete(session_id: str, request: Request):
|
|
||||||
"""Delete a research result from disk."""
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
data_dir = Path(DEEP_RESEARCH_DIR)
|
|
||||||
json_path = data_dir / f"{session_id}.json"
|
|
||||||
deleted = False
|
|
||||||
if json_path.exists():
|
|
||||||
# SECURITY: verify ownership before letting the caller delete it.
|
|
||||||
try:
|
|
||||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
|
||||||
if data.get("owner") != user:
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(404, "Research not found")
|
|
||||||
json_path.unlink()
|
|
||||||
deleted = True
|
|
||||||
return {"deleted": deleted}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Panel endpoints — launch research without a chat session
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ResearchStartRequest(BaseModel):
|
|
||||||
query: str
|
|
||||||
# max_rounds=0 means "Auto" — let the AI decide when to stop, capped at 20.
|
|
||||||
max_rounds: int = Field(default=0, ge=0, le=20)
|
|
||||||
search_provider: Optional[str] = None
|
|
||||||
endpoint_id: Optional[str] = None
|
|
||||||
model: Optional[str] = None
|
|
||||||
max_time: int = Field(default=300, ge=60, le=1800)
|
|
||||||
extraction_timeout: Optional[int] = Field(default=None, ge=15, le=3600)
|
|
||||||
extraction_concurrency: Optional[int] = Field(default=None, ge=1, le=12)
|
|
||||||
category: Optional[str] = None
|
|
||||||
|
|
||||||
@router.post("/api/research/start")
|
|
||||||
async def research_start(body: ResearchStartRequest, request: Request):
|
|
||||||
"""Launch a research job from the dedicated panel."""
|
|
||||||
from src.auth_helpers import require_privilege
|
|
||||||
user = require_privilege(request, "can_use_research")
|
|
||||||
if user == INTERNAL_TOOL_USER:
|
|
||||||
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
|
||||||
if tool_owner and tool_owner not in RESERVED_USERNAMES:
|
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
|
||||||
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
|
||||||
try:
|
|
||||||
privs = auth_mgr.get_privileges(tool_owner) or {}
|
|
||||||
if not privs.get("can_use_research", True):
|
|
||||||
raise HTTPException(403, f"Your account is not allowed to can use research.")
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
user = tool_owner
|
|
||||||
session_id = f"rp-{uuid.uuid4().hex[:12]}"
|
|
||||||
|
|
||||||
if body.endpoint_id:
|
|
||||||
from src.database import SessionLocal
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# Owner-scoped: never resolve another user's private endpoint
|
|
||||||
# (and its decrypted api_key / internal base_url). A scoped miss
|
|
||||||
# reads as 404 so the endpoint's existence isn't revealed.
|
|
||||||
ep = _owned_enabled_endpoint(db, user, body.endpoint_id)
|
|
||||||
if not ep:
|
|
||||||
raise HTTPException(404, "Endpoint not found or disabled")
|
|
||||||
resolved = _resolve_endpoint_runtime(ep, owner=user, model=body.model)
|
|
||||||
if not resolved:
|
|
||||||
raise HTTPException(400, "Endpoint is not configured with a usable model.")
|
|
||||||
ep_url, ep_model, ep_headers = resolved
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
else:
|
|
||||||
ep_url, ep_model, ep_headers = resolve_endpoint("research", owner=user)
|
|
||||||
if not ep_url:
|
|
||||||
ep_url, ep_model, ep_headers = resolve_endpoint("utility", owner=user)
|
|
||||||
# When neither research nor utility is configured, use the user's
|
|
||||||
# configured DEFAULT model (default_endpoint_id/default_model) rather
|
|
||||||
# than arbitrarily grabbing the first enabled endpoint's first model
|
|
||||||
# (which surfaced gpt-3.5). "Default" should mean the default model.
|
|
||||||
if not ep_url:
|
|
||||||
ep_url, ep_model, ep_headers = resolve_endpoint("default", owner=user)
|
|
||||||
if not ep_url:
|
|
||||||
ep_url, ep_model, ep_headers = resolve_endpoint("chat", owner=user)
|
|
||||||
if not ep_url:
|
|
||||||
from src.database import SessionLocal
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# Owner-scoped first-enabled fallback: the caller's own rows
|
|
||||||
# + legacy null-owner shared rows only — never borrow another
|
|
||||||
# user's private endpoint/api_key. Same fix as the
|
|
||||||
# /api/v1/chat fallback (webhook_routes._first_enabled_endpoint).
|
|
||||||
ep = _owned_enabled_endpoint(db, user)
|
|
||||||
if ep:
|
|
||||||
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
|
||||||
if resolved:
|
|
||||||
ep_url, ep_model, ep_headers = resolved
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
if not ep_url:
|
|
||||||
raise HTTPException(400, "No endpoints configured. Add one in Settings first.")
|
|
||||||
if body.model:
|
|
||||||
ep_model = body.model
|
|
||||||
|
|
||||||
# max_rounds=0 → "Auto", let AI decide; pass 20 as the safety cap.
|
|
||||||
effective_max_rounds = body.max_rounds if body.max_rounds > 0 else 20
|
|
||||||
research_handler.start_research(
|
|
||||||
session_id=session_id,
|
|
||||||
query=body.query,
|
|
||||||
llm_endpoint=ep_url,
|
|
||||||
llm_model=ep_model,
|
|
||||||
max_time=body.max_time,
|
|
||||||
llm_headers=ep_headers,
|
|
||||||
max_rounds=effective_max_rounds,
|
|
||||||
search_provider=body.search_provider or None,
|
|
||||||
category=body.category or None,
|
|
||||||
extraction_timeout=body.extraction_timeout,
|
|
||||||
extraction_concurrency=body.extraction_concurrency,
|
|
||||||
owner=user,
|
|
||||||
)
|
|
||||||
return {"session_id": session_id, "status": "running", "query": body.query}
|
|
||||||
|
|
||||||
@router.get("/api/research/stream/{session_id}")
|
|
||||||
async def research_stream(session_id: str, request: Request):
|
|
||||||
"""SSE stream of research progress events."""
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
if not _owns_in_memory(session_id, user):
|
|
||||||
raise HTTPException(404, "No research found for this session")
|
|
||||||
async def _generate():
|
|
||||||
last_progress = None
|
|
||||||
while True:
|
|
||||||
status = research_handler.get_status(session_id)
|
|
||||||
if status is None:
|
|
||||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
|
||||||
return
|
|
||||||
st = status.get("status", "")
|
|
||||||
progress = status.get("progress", {})
|
|
||||||
if progress != last_progress:
|
|
||||||
last_progress = progress
|
|
||||||
yield f"data: {json.dumps({**progress, 'status': st})}\n\n"
|
|
||||||
if st != "running":
|
|
||||||
final = {'status': st, 'final': True}
|
|
||||||
task = research_handler._active_tasks.get(session_id, {})
|
|
||||||
if st == "error" and task.get("result"):
|
|
||||||
final['error'] = str(task["result"])[:500]
|
|
||||||
yield f"data: {json.dumps(final)}\n\n"
|
|
||||||
return
|
|
||||||
await asyncio.sleep(1.5)
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
_generate(),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/api/research/result-peek/{session_id}")
|
|
||||||
async def research_result_peek(session_id: str, request: Request):
|
|
||||||
"""Get research result without clearing it (for panel use)."""
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
if not _owns_in_memory(session_id, user):
|
|
||||||
raise HTTPException(404, "No research found for this session")
|
|
||||||
result = research_handler.get_result(session_id)
|
|
||||||
if result is None:
|
|
||||||
p = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
|
||||||
if p.exists():
|
|
||||||
d = json.loads(p.read_text(encoding="utf-8"))
|
|
||||||
return {
|
|
||||||
"result": d.get("result", ""),
|
|
||||||
"sources": d.get("sources", []),
|
|
||||||
"raw_findings": d.get("raw_findings", []),
|
|
||||||
"category": d.get("category") or "",
|
|
||||||
}
|
|
||||||
raise HTTPException(404, "No research result available")
|
|
||||||
sources = research_handler.get_sources(session_id) or []
|
|
||||||
raw_findings = research_handler.get_raw_findings(session_id) or []
|
|
||||||
return {"result": result, "sources": sources, "raw_findings": raw_findings, "category": ""}
|
|
||||||
|
|
||||||
@router.post("/api/research/spinoff/{session_id}")
|
|
||||||
async def research_spinoff(session_id: str, request: Request):
|
|
||||||
"""Create a new chat session pre-seeded with this research as context.
|
|
||||||
|
|
||||||
Reads the persisted research result + sources for `session_id`, creates
|
|
||||||
a fresh session (inheriting endpoint/model/headers from the source
|
|
||||||
session if available, otherwise from the resolved chat endpoint), and
|
|
||||||
injects a single system message containing the report and sources so
|
|
||||||
the user can ask follow-up questions in a clean conversation.
|
|
||||||
"""
|
|
||||||
user = _require_user(request)
|
|
||||||
_validate_session_id(session_id)
|
|
||||||
# SECURITY: gate on ownership before reading the persisted research —
|
|
||||||
# otherwise any authenticated user could spin off (and thereby read)
|
|
||||||
# another user's report by guessing its session ID. Mirrors every other
|
|
||||||
# endpoint in this file (see result_peek above).
|
|
||||||
if not _owns_in_memory(session_id, user):
|
|
||||||
raise HTTPException(404, "No research found for this session")
|
|
||||||
if session_manager is None:
|
|
||||||
raise HTTPException(500, "session_manager not configured")
|
|
||||||
|
|
||||||
# Load research data — prefer in-memory result, fall back to disk
|
|
||||||
result = research_handler.get_result(session_id)
|
|
||||||
sources = research_handler.get_sources(session_id) or []
|
|
||||||
query = ""
|
|
||||||
|
|
||||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
|
||||||
if path.exists():
|
|
||||||
try:
|
|
||||||
disk = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
if not result:
|
|
||||||
result = disk.get("result")
|
|
||||||
if not sources:
|
|
||||||
sources = disk.get("sources", []) or []
|
|
||||||
query = disk.get("query", "") or ""
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not read research JSON for spinoff: {e}")
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(404, "No research result available for this session")
|
|
||||||
|
|
||||||
# Inherit endpoint/model/headers from the source session when possible.
|
|
||||||
# For panel-launched research (rp-* IDs), there is no chat session, so
|
|
||||||
# fall back through the same chain as /api/research/start: research →
|
|
||||||
# utility → first enabled endpoint in the DB.
|
|
||||||
ep_url, ep_model, ep_headers = "", "", {}
|
|
||||||
try:
|
|
||||||
src_sess = session_manager.get_session(session_id)
|
|
||||||
ep_url = src_sess.endpoint_url or ""
|
|
||||||
ep_model = src_sess.model or ""
|
|
||||||
ep_headers = dict(src_sess.headers or {})
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _merge(r_url, r_model, r_headers):
|
|
||||||
nonlocal ep_url, ep_model, ep_headers
|
|
||||||
if not ep_url and r_url:
|
|
||||||
ep_url = r_url
|
|
||||||
if not ep_model and r_model:
|
|
||||||
ep_model = r_model
|
|
||||||
if not ep_headers and r_headers:
|
|
||||||
ep_headers = dict(r_headers)
|
|
||||||
|
|
||||||
if not ep_url or not ep_model:
|
|
||||||
_merge(*resolve_endpoint("chat", owner=user))
|
|
||||||
if not ep_url or not ep_model:
|
|
||||||
_merge(*resolve_endpoint("research", owner=user))
|
|
||||||
if not ep_url or not ep_model:
|
|
||||||
_merge(*resolve_endpoint("utility", owner=user))
|
|
||||||
if not ep_url or not ep_model:
|
|
||||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
|
||||||
from src.database import SessionLocal
|
|
||||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
ep = _owned_enabled_endpoint(db, user)
|
|
||||||
if ep:
|
|
||||||
base = normalize_base(ep.base_url)
|
|
||||||
fallback_url = build_chat_url(base)
|
|
||||||
fallback_headers = build_headers(ep.api_key, base)
|
|
||||||
fallback_model = ""
|
|
||||||
if ep.cached_models:
|
|
||||||
try:
|
|
||||||
models = json.loads(ep.cached_models)
|
|
||||||
if models:
|
|
||||||
fallback_model = _first_chat_model(models)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_merge(fallback_url, fallback_model, fallback_headers)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if not ep_url or not ep_model:
|
|
||||||
raise HTTPException(400, "No endpoint configured — add one in Settings first")
|
|
||||||
|
|
||||||
# Create new session
|
|
||||||
new_sid = str(uuid.uuid4())
|
|
||||||
|
|
||||||
title_query = (query or "research").strip()
|
|
||||||
if len(title_query) > 60:
|
|
||||||
title_query = title_query[:57] + "…"
|
|
||||||
new_name = f"Follow-up: {title_query}"
|
|
||||||
|
|
||||||
new_sess = session_manager.create_session(
|
|
||||||
session_id=new_sid,
|
|
||||||
name=new_name,
|
|
||||||
endpoint_url=ep_url,
|
|
||||||
model=ep_model,
|
|
||||||
rag=False,
|
|
||||||
owner=user,
|
|
||||||
)
|
|
||||||
if ep_headers:
|
|
||||||
new_sess.headers = ep_headers
|
|
||||||
session_manager.save_sessions()
|
|
||||||
try:
|
|
||||||
from src.event_bus import fire_event
|
|
||||||
fire_event("session_created", user)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("session_created event dispatch failed", exc_info=True)
|
|
||||||
|
|
||||||
# Build the priming system message — report only, no sources injected.
|
|
||||||
# The user can open the visual report for source details; keeping sources
|
|
||||||
# out of the chat context saves tokens and avoids the AI fabricating
|
|
||||||
# citations.
|
|
||||||
date_str = datetime.utcnow().strftime("%Y-%m-%d")
|
|
||||||
primer = (
|
|
||||||
f"[Research context — {date_str}]\n\n"
|
|
||||||
f"The user previously ran a deep research investigation. Use the "
|
|
||||||
f"report below as your primary knowledge base when answering "
|
|
||||||
f"follow-up questions. If the user asks something not covered, "
|
|
||||||
f"say so plainly rather than guessing.\n\n"
|
|
||||||
f"=== ORIGINAL QUERY ===\n{query or '(not recorded)'}\n\n"
|
|
||||||
f"=== REPORT ===\n{result}"
|
|
||||||
)
|
|
||||||
|
|
||||||
from core.models import ChatMessage
|
|
||||||
new_sess.add_message(ChatMessage(
|
|
||||||
role="system",
|
|
||||||
content=primer,
|
|
||||||
metadata={"research_spinoff_from": session_id},
|
|
||||||
))
|
|
||||||
session_manager.save_sessions()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"session_id": new_sid,
|
|
||||||
"name": new_name,
|
|
||||||
"source_count": len(sources),
|
|
||||||
}
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|||||||
+36
-13
@@ -13,6 +13,7 @@ from src.request_models import SessionResponse
|
|||||||
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
||||||
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
|
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
|
||||||
from src.session_actions import is_session_recently_active
|
from src.session_actions import is_session_recently_active
|
||||||
|
from src.upload_handler import reserve_message_upload_references
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_export_filename(name: str) -> str:
|
def _sanitize_export_filename(name: str) -> str:
|
||||||
@@ -162,7 +163,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()
|
||||||
@@ -203,10 +204,16 @@ def _pick_endpoint_for_sort(owner=None):
|
|||||||
return url, model, headers
|
return url, model, headers
|
||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
def setup_session_routes(session_manager: SessionManager, config: dict, webhook_manager=None):
|
def setup_session_routes(
|
||||||
|
session_manager: SessionManager,
|
||||||
|
config: dict,
|
||||||
|
webhook_manager=None,
|
||||||
|
upload_handler=None,
|
||||||
|
):
|
||||||
"""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 +230,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 +381,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 +401,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 +477,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 +524,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()
|
||||||
@@ -536,6 +543,22 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
body = await request.json()
|
body = await request.json()
|
||||||
messages = body.get("messages", [])
|
messages = body.get("messages", [])
|
||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
|
owner = effective_user(request)
|
||||||
|
try:
|
||||||
|
for message in messages:
|
||||||
|
missing_id = reserve_message_upload_references(
|
||||||
|
upload_handler,
|
||||||
|
owner,
|
||||||
|
message.get("content"),
|
||||||
|
message.get("metadata"),
|
||||||
|
)
|
||||||
|
if missing_id:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
)
|
||||||
|
except (AttributeError, TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(400, "Invalid message attachment metadata") from exc
|
||||||
for m in messages:
|
for m in messages:
|
||||||
sess.add_message(ChatMessage(m["role"], m["content"], metadata=m.get("metadata")))
|
sess.add_message(ChatMessage(m["role"], m["content"], metadata=m.get("metadata")))
|
||||||
session_manager.save_sessions()
|
session_manager.save_sessions()
|
||||||
@@ -646,7 +669,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 +703,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 +913,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 +1002,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 +1279,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:
|
||||||
|
|||||||
+481
-35
@@ -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():
|
||||||
@@ -331,7 +329,12 @@ def add_user_install_bins_to_path():
|
|||||||
candidates.append(os.path.join(site.USER_BASE, 'bin'))
|
candidates.append(os.path.join(site.USER_BASE, 'bin'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
candidates.append(os.path.expanduser('~/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('~/.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]):
|
||||||
@@ -406,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`.
|
||||||
|
|
||||||
@@ -822,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
|
||||||
@@ -962,12 +1010,84 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
|
|
||||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
def _os_id_from_release(text: str) -> str:
|
||||||
|
"""Map /etc/os-release contents to a canonical family for our matrix."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
ids = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("ID=") or line.startswith("ID_LIKE="):
|
||||||
|
ids += line.split("=", 1)[1].strip().strip('"').split()
|
||||||
|
ids = [i.lower() for i in ids]
|
||||||
|
if any(x in ids for x in ("debian", "ubuntu", "linuxmint", "pop", "elementary")):
|
||||||
|
return "debian"
|
||||||
|
if any(x in ids for x in ("arch", "manjaro", "endeavouros", "cachyos", "garuda")):
|
||||||
|
return "arch"
|
||||||
|
if any(x in ids for x in ("fedora", "rhel", "centos", "rocky", "almalinux", "ol")):
|
||||||
|
return "fedora"
|
||||||
|
if "alpine" in ids:
|
||||||
|
return "alpine"
|
||||||
|
if any(x in ids for x in ("suse", "opensuse", "opensuse-leap", "opensuse-tumbleweed", "sles")):
|
||||||
|
return "suse"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Matrix lookup keyed on (os_family, backend) → (pkg_mgr_cmd_template, pkg_list_per_dep).
|
||||||
|
# Each `system_prereqs` name resolves to a list of OS-specific package
|
||||||
|
# names that get joined into the final `sudo apt install -y …` etc.
|
||||||
|
# command. Backend-specific extras (CUDA toolkit, ROCm, Vulkan headers)
|
||||||
|
# are added only when the detected backend needs them.
|
||||||
|
_PKG_NAMES = {
|
||||||
|
# canonical-name → {os_id: [actual_pkg_names_on_this_os]}
|
||||||
|
"cmake": {"debian": ["cmake"], "arch": ["cmake"], "fedora": ["cmake"], "alpine": ["cmake"], "suse": ["cmake"], "macos": ["cmake"]},
|
||||||
|
"build-essential": {"debian": ["build-essential"], "arch": ["base-devel"], "fedora": ["gcc", "gcc-c++", "make"], "alpine": ["build-base"], "suse": ["gcc-c++", "make"], "macos": []},
|
||||||
|
"g++": {"debian": ["g++"], "arch": ["gcc"], "fedora": ["gcc-c++"], "alpine": ["g++"], "suse": ["gcc-c++"], "macos": []},
|
||||||
|
"gcc": {"debian": ["gcc"], "arch": ["gcc"], "fedora": ["gcc"], "alpine": ["gcc"], "suse": ["gcc"], "macos": []},
|
||||||
|
"make": {"debian": ["make"], "arch": ["make"], "fedora": ["make"], "alpine": ["make"], "suse": ["make"], "macos": []},
|
||||||
|
"git": {"debian": ["git"], "arch": ["git"], "fedora": ["git"], "alpine": ["git"], "suse": ["git"], "macos": ["git"]},
|
||||||
|
"tmux": {"debian": ["tmux"], "arch": ["tmux"], "fedora": ["tmux"], "alpine": ["tmux"], "suse": ["tmux"], "macos": ["tmux"]},
|
||||||
|
}
|
||||||
|
_BACKEND_EXTRAS = {
|
||||||
|
"cuda": {"debian": ["nvidia-cuda-toolkit"], "arch": ["cuda"], "fedora": ["cuda-toolkit"], "alpine": [], "suse": ["cuda"], "macos": []},
|
||||||
|
"rocm": {"debian": ["rocm-dev"], "arch": ["rocm-hip-sdk"], "fedora": ["rocm-devel"], "alpine": [], "suse": ["rocm-dev"], "macos": []},
|
||||||
|
"vulkan": {"debian": ["libvulkan-dev", "vulkan-tools"], "arch": ["vulkan-headers", "vulkan-tools"], "fedora": ["vulkan-headers", "vulkan-tools"], "alpine": ["vulkan-loader-dev", "vulkan-tools"], "suse": ["vulkan-devel", "vulkan-tools"], "macos": []},
|
||||||
|
}
|
||||||
|
_PKG_MGR = {
|
||||||
|
"debian": "sudo apt install -y {pkgs}",
|
||||||
|
"arch": "sudo pacman -S --needed {pkgs}",
|
||||||
|
"fedora": "sudo dnf install -y {pkgs}",
|
||||||
|
"alpine": "sudo apk add {pkgs}",
|
||||||
|
"suse": "sudo zypper install -n {pkgs}",
|
||||||
|
"macos": "brew install {pkgs}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _install_cmd_for_target(os_id: str, backend: str, missing: list[str]) -> str:
|
||||||
|
"""Build a single OS+backend-aware install command for the missing prereqs."""
|
||||||
|
if not os_id or os_id not in _PKG_MGR:
|
||||||
|
return ""
|
||||||
|
pkgs: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for m in missing:
|
||||||
|
for p in _PKG_NAMES.get(m, {}).get(os_id, []):
|
||||||
|
if p not in seen:
|
||||||
|
pkgs.append(p); seen.add(p)
|
||||||
|
# Add backend-specific extras only when the build would actually
|
||||||
|
# consume them (a CUDA toolkit isn't useful on a Vulkan box).
|
||||||
|
backend = (backend or "").lower()
|
||||||
|
for p in _BACKEND_EXTRAS.get(backend, {}).get(os_id, []):
|
||||||
|
if p not in seen:
|
||||||
|
pkgs.append(p); seen.add(p)
|
||||||
|
if not pkgs:
|
||||||
|
return ""
|
||||||
|
return _PKG_MGR[os_id].format(pkgs=" ".join(pkgs))
|
||||||
|
|
||||||
@router.get("/api/cookbook/packages")
|
@router.get("/api/cookbook/packages")
|
||||||
async def list_packages(
|
async def list_packages(
|
||||||
request: Request,
|
request: Request,
|
||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
ssh_port: str | None = None,
|
ssh_port: str | None = None,
|
||||||
venv: str | None = None,
|
venv: str | None = None,
|
||||||
|
backend: str | None = None,
|
||||||
):
|
):
|
||||||
"""Check which optional packages are installed.
|
"""Check which optional packages are installed.
|
||||||
|
|
||||||
@@ -988,8 +1108,19 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
importlib.invalidate_caches()
|
importlib.invalidate_caches()
|
||||||
try:
|
try:
|
||||||
user_site = site.getusersitepackages()
|
user_site = site.getusersitepackages()
|
||||||
if user_site and os.path.isdir(user_site) and user_site not in sys.path:
|
if user_site and os.path.isdir(user_site):
|
||||||
sys.path.append(user_site)
|
# Use addsitedir(), NOT a bare sys.path.append(). When a package
|
||||||
|
# is `pip install --user`'d at runtime (Cookbook → Install) the
|
||||||
|
# long-lived server process started before the user-site existed,
|
||||||
|
# so site never processed it — including its `.pth` hooks. On
|
||||||
|
# Python 3.12+ `distutils` is gone from stdlib and is only
|
||||||
|
# restored by setuptools' `distutils-precedence.pth`, which ships
|
||||||
|
# in user-site. basicsr (a realesrgan dep) does `import distutils`
|
||||||
|
# at import time, so a plain append left the package importable
|
||||||
|
# but `import distutils` failing → realesrgan probed as
|
||||||
|
# not-installed until a full process restart. addsitedir() replays
|
||||||
|
# the `.pth` files so the shim is active.
|
||||||
|
site.addsitedir(user_site)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if ssh_port and str(ssh_port).strip() not in ("", "22"):
|
if ssh_port and str(ssh_port).strip() not in ("", "22"):
|
||||||
@@ -1016,6 +1147,12 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"kind": "system",
|
"kind": "system",
|
||||||
"install_hint": "Install Docker on the selected server and allow this user to run docker.",
|
"install_hint": "Install Docker on the selected server and allow this user to run docker.",
|
||||||
},
|
},
|
||||||
|
# Note: cmake / gcc / git are not separate dependency rows —
|
||||||
|
# they're declared as `system_prereqs` on llama_cpp (and any
|
||||||
|
# other engine that compiles from source) so they appear as
|
||||||
|
# an inline status note on that engine's row instead of
|
||||||
|
# cluttering the panel with raw OS package names that aren't
|
||||||
|
# meaningful product-level dependencies on their own.
|
||||||
# ── LLM ── installs on GPU servers for model serving/downloading
|
# ── LLM ── installs on GPU servers for model serving/downloading
|
||||||
{
|
{
|
||||||
"name": "hf_transfer",
|
"name": "hf_transfer",
|
||||||
@@ -1027,9 +1164,16 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
{
|
{
|
||||||
"name": "llama_cpp",
|
"name": "llama_cpp",
|
||||||
"pip": "llama-cpp-python[server]",
|
"pip": "llama-cpp-python[server]",
|
||||||
"desc": "Serve GGUF models via llama.cpp",
|
"desc": "Great for single-GPU or CPU inference with GGUF models",
|
||||||
"category": "LLM",
|
"category": "LLM",
|
||||||
"target": "remote",
|
"target": "remote",
|
||||||
|
# Build-toolchain prereqs. Cookbook's launch bootstrap
|
||||||
|
# compiles llama-server from source when no prebuilt
|
||||||
|
# binary is present; without these the build aborts
|
||||||
|
# with `cmake: command not found`. Surfaced inline on
|
||||||
|
# this row so the user doesn't have to chase three
|
||||||
|
# separate OS-package rows.
|
||||||
|
"system_prereqs": ["cmake", "g++", "git"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sglang",
|
"name": "sglang",
|
||||||
@@ -1041,7 +1185,14 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
{
|
{
|
||||||
"name": "vllm",
|
"name": "vllm",
|
||||||
"pip": "vllm",
|
"pip": "vllm",
|
||||||
"desc": "High-throughput LLM serving engine",
|
"desc": "Great for high-throughput multi-GPU inference",
|
||||||
|
"category": "LLM",
|
||||||
|
"target": "remote",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mlx_lm",
|
||||||
|
"pip": "mlx-lm",
|
||||||
|
"desc": "Serve MLX-format models on Apple Silicon Macs",
|
||||||
"category": "LLM",
|
"category": "LLM",
|
||||||
"target": "remote",
|
"target": "remote",
|
||||||
},
|
},
|
||||||
@@ -1060,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",
|
||||||
},
|
},
|
||||||
@@ -1104,6 +1255,7 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
# venv over SSH so a remote `pip install` actually reflects here.
|
# venv over SSH so a remote `pip install` actually reflects here.
|
||||||
remote_status: dict = {}
|
remote_status: dict = {}
|
||||||
remote_details: dict = {}
|
remote_details: dict = {}
|
||||||
|
remote_probe_error = ""
|
||||||
remote_names = [
|
remote_names = [
|
||||||
p["name"]
|
p["name"]
|
||||||
for p in packages
|
for p in packages
|
||||||
@@ -1142,16 +1294,56 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
break
|
break
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(400, str(e))
|
raise HTTPException(400, str(e))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
remote_status = {}
|
remote_status = {}
|
||||||
if host and remote_system_names:
|
remote_probe_error = f"SSH package probe failed: {str(e)[:160]}"
|
||||||
|
if "llama_cpp" in remote_names:
|
||||||
|
try:
|
||||||
|
inner = (
|
||||||
|
'export PATH="$HOME/.local/bin:$HOME/bin:'
|
||||||
|
'$HOME/llama.cpp/build/bin:$HOME/llama.cpp/build-vulkan/bin:$PATH"; '
|
||||||
|
"command -v llama-server 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
argv = _ssh_base_argv(host, ssh_port) + [inner]
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*argv,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
out, _err = await asyncio.wait_for(proc.communicate(), timeout=8)
|
||||||
|
llama_server_path = out.decode("utf-8", errors="replace").strip().splitlines()
|
||||||
|
llama_server_path = llama_server_path[-1].strip() if llama_server_path else ""
|
||||||
|
if llama_server_path:
|
||||||
|
remote_status["llama_cpp"] = True
|
||||||
|
probe = remote_details.setdefault("llama_cpp", {})
|
||||||
|
if isinstance(probe, dict):
|
||||||
|
probe.setdefault("binaries", {})["llama-server"] = llama_server_path
|
||||||
|
except Exception as e:
|
||||||
|
if not remote_probe_error:
|
||||||
|
remote_probe_error = f"SSH llama-server probe failed: {str(e)[:160]}"
|
||||||
|
pass
|
||||||
|
# Union of system_names + every package's system_prereqs. Probing
|
||||||
|
# the prereqs alongside the main system deps in a single SSH call
|
||||||
|
# avoids a second round-trip per Cookbook → Dependencies refresh.
|
||||||
|
prereq_names: set[str] = set()
|
||||||
|
for p in packages:
|
||||||
|
for pr in p.get("system_prereqs") or []:
|
||||||
|
prereq_names.add(str(pr))
|
||||||
|
all_system_names = list(set(remote_system_names) | prereq_names)
|
||||||
|
# Detect the target's OS family + read /etc/os-release in the same
|
||||||
|
# SSH round-trip as the prereq probe — used downstream to render a
|
||||||
|
# single OS-specific install command per row instead of dumping
|
||||||
|
# every distro's syntax onto the user.
|
||||||
|
target_os_id: str = ""
|
||||||
|
if host and all_system_names:
|
||||||
try:
|
try:
|
||||||
checks = []
|
checks = []
|
||||||
for name in remote_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 || { [ \"$(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(
|
||||||
@@ -1161,19 +1353,44 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
)
|
)
|
||||||
out, _err = await asyncio.wait_for(proc.communicate(), timeout=12)
|
out, _err = await asyncio.wait_for(proc.communicate(), timeout=12)
|
||||||
txt = out.decode("utf-8", errors="replace").strip()
|
txt = out.decode("utf-8", errors="replace").strip()
|
||||||
|
_section, _osrel_lines = "probe", []
|
||||||
for line in txt.splitlines():
|
for line in txt.splitlines():
|
||||||
|
if line.strip() == "---OSREL---":
|
||||||
|
_section = "osrel"; continue
|
||||||
|
if _section == "osrel":
|
||||||
|
_osrel_lines.append(line)
|
||||||
|
continue
|
||||||
name, sep, value = line.strip().partition("=")
|
name, sep, value = line.strip().partition("=")
|
||||||
if sep and name in remote_system_names:
|
if sep and name in all_system_names:
|
||||||
remote_status[name] = value == "1"
|
remote_status[name] = value == "1"
|
||||||
|
target_os_id = _os_id_from_release("\n".join(_osrel_lines))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(400, str(e))
|
raise HTTPException(400, str(e))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
if not remote_probe_error:
|
||||||
|
remote_probe_error = f"SSH system probe failed: {str(e)[:160]}"
|
||||||
pass
|
pass
|
||||||
|
elif not host:
|
||||||
|
# Local target — probe in-process so the inline install command
|
||||||
|
# still appears in the dep panel when the cookbook container
|
||||||
|
# itself is the selected server.
|
||||||
|
try:
|
||||||
|
with open("/etc/os-release", encoding="utf-8") as f:
|
||||||
|
target_os_id = _os_id_from_release(f.read())
|
||||||
|
except Exception:
|
||||||
|
target_os_id = ""
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
target_os_id = "macos"
|
||||||
|
|
||||||
for pkg in packages:
|
for pkg in packages:
|
||||||
on_remote = bool(host and pkg.get("target") == "remote")
|
on_remote = bool(host and pkg.get("target") == "remote")
|
||||||
probe = None
|
probe = None
|
||||||
if on_remote:
|
if on_remote:
|
||||||
|
if remote_probe_error and pkg["name"] not in remote_status:
|
||||||
|
pkg["installed"] = None
|
||||||
|
pkg["probe_error"] = remote_probe_error
|
||||||
|
pkg["status_note"] = remote_probe_error
|
||||||
|
else:
|
||||||
pkg["installed"] = bool(remote_status.get(pkg["name"], False))
|
pkg["installed"] = bool(remote_status.get(pkg["name"], False))
|
||||||
probe = remote_details.get(pkg["name"])
|
probe = remote_details.get(pkg["name"])
|
||||||
if isinstance(probe, dict):
|
if isinstance(probe, dict):
|
||||||
@@ -1223,13 +1440,116 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
pkg["installed"] = False
|
pkg["installed"] = False
|
||||||
except importlib_metadata.PackageNotFoundError:
|
except importlib_metadata.PackageNotFoundError:
|
||||||
pkg["installed"] = False
|
pkg["installed"] = False
|
||||||
except Exception:
|
except (Exception, SystemExit):
|
||||||
# Installed but crashes on import — e.g. a CUDA build of
|
# Installed but crashes on import — e.g. a CUDA build of
|
||||||
# llama-cpp-python raising FileNotFoundError when the CUDA
|
# llama-cpp-python raising FileNotFoundError when the CUDA
|
||||||
# toolkit dir is absent. One broken optional package must not
|
# toolkit dir is absent, or rembg calling sys.exit(1) when no
|
||||||
# 500 the entire packages panel; report it as not usable.
|
# onnxruntime backend can be loaded. SystemExit is a
|
||||||
|
# BaseException, not Exception, so without catching it here a
|
||||||
|
# single sys.exit-on-import package escapes and takes down the
|
||||||
|
# whole packages panel / worker (the panel hangs forever). One
|
||||||
|
# broken optional package must not 500 — or hang — the entire
|
||||||
|
# panel; report it as not usable.
|
||||||
pkg["installed"] = False
|
pkg["installed"] = False
|
||||||
|
|
||||||
|
# llama_cpp partial-state probe: when the package is installed
|
||||||
|
# but the wheel was built CPU-only AND the target has NVIDIA
|
||||||
|
# hardware, mark the row as partial (yellow/orange) with a
|
||||||
|
# one-click upgrade to the CUDA wheel. Without this the row
|
||||||
|
# reads "ready" green while inference runs at 3 tok/s on GPU
|
||||||
|
# silicon — actively misleading.
|
||||||
|
if pkg["name"] == "llama_cpp" and pkg.get("installed"):
|
||||||
|
_native_llama_server = bool(
|
||||||
|
isinstance(probe, dict)
|
||||||
|
and isinstance(probe.get("binaries"), dict)
|
||||||
|
and probe["binaries"].get("llama-server")
|
||||||
|
)
|
||||||
|
_gpu_capable = False
|
||||||
|
_has_nvidia_target = False
|
||||||
|
if _native_llama_server:
|
||||||
|
# Native llama-server is the launcher path Cookbook now
|
||||||
|
# prefers. Do not mark this as a CPU-only Python wheel just
|
||||||
|
# because llama-cpp-python is absent from the selected venv.
|
||||||
|
_gpu_capable = True
|
||||||
|
elif on_remote and host:
|
||||||
|
try:
|
||||||
|
# Activate the configured venv FIRST so the probe
|
||||||
|
# runs against the same python the launch script
|
||||||
|
# would activate. Without this prefix, bare
|
||||||
|
# `python3` was checked — which can disagree with
|
||||||
|
# the venv's wheel (e.g. user-site has CUDA wheel
|
||||||
|
# but venv has CPU-only), and the dep panel then
|
||||||
|
# showed "ready" green while every launch fell to
|
||||||
|
# CPU.
|
||||||
|
_vp = _venv_activate_prefix(venv)
|
||||||
|
probe = (
|
||||||
|
f'{_vp}python3 -c "import llama_cpp; import sys; '
|
||||||
|
'sys.exit(0 if llama_cpp.llama_supports_gpu_offload() else 1)" '
|
||||||
|
'&& echo llama_cpp_gpu=1 || echo llama_cpp_gpu=0; '
|
||||||
|
'command -v nvidia-smi >/dev/null 2>&1 '
|
||||||
|
'&& nvidia-smi -L 2>/dev/null | grep -q "GPU " '
|
||||||
|
'&& echo nvidia=1 || echo nvidia=0'
|
||||||
|
)
|
||||||
|
argv = _ssh_base_argv(host, ssh_port) + [probe]
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
out, _ = await asyncio.wait_for(proc.communicate(), timeout=8)
|
||||||
|
txt = out.decode("utf-8", errors="replace")
|
||||||
|
if "llama_cpp_gpu=1" in txt:
|
||||||
|
_gpu_capable = True
|
||||||
|
if "nvidia=1" in txt:
|
||||||
|
_has_nvidia_target = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import llama_cpp as _lcp # type: ignore
|
||||||
|
_gpu_capable = bool(_lcp.llama_supports_gpu_offload())
|
||||||
|
except Exception:
|
||||||
|
_gpu_capable = False
|
||||||
|
_has_nvidia_target = shutil.which("nvidia-smi") is not None
|
||||||
|
if (not _gpu_capable) and _has_nvidia_target:
|
||||||
|
pkg["partial"] = True
|
||||||
|
pkg["partial_reason"] = "Installed but CPU-only wheel — GPU detected on this target. Upgrade to a CUDA wheel for ~10× faster inference."
|
||||||
|
pkg["partial_action"] = "reinstall_llama_cpp_cuda"
|
||||||
|
# Attach per-package system_prereqs status. We probed each
|
||||||
|
# prereq name above; surface "Missing build deps: …" ONLY
|
||||||
|
# when the package itself is not installed — if the package
|
||||||
|
# works (e.g. llama-cpp-python already imports cleanly), the
|
||||||
|
# build toolchain is irrelevant and surfacing it as a red
|
||||||
|
# flag confuses users ("ready" + "missing" on the same row).
|
||||||
|
_prereqs = list(pkg.get("system_prereqs") or [])
|
||||||
|
if _prereqs:
|
||||||
|
if on_remote:
|
||||||
|
_pr_present = {n: bool(remote_status.get(n)) for n in _prereqs}
|
||||||
|
else:
|
||||||
|
_pr_present = {n: shutil.which(n) is not None for n in _prereqs}
|
||||||
|
pkg["system_prereqs_status"] = _pr_present
|
||||||
|
_missing = [n for n, ok in _pr_present.items() if not ok]
|
||||||
|
# Suppress the "missing build deps" hint when the package
|
||||||
|
# itself is installed — build deps are only relevant if
|
||||||
|
# the user would need to recompile from source.
|
||||||
|
if pkg.get("installed"):
|
||||||
|
_missing = []
|
||||||
|
if _missing:
|
||||||
|
# Build a target-specific install command from the
|
||||||
|
# (os_family, backend) matrix when we know both. Fall
|
||||||
|
# back to the multi-distro hint only when the target's
|
||||||
|
# OS can't be classified (e.g. ssh probe failed).
|
||||||
|
_resolved_os = target_os_id or "debian" # safest default
|
||||||
|
_cmd = _install_cmd_for_target(_resolved_os, backend or "", _missing)
|
||||||
|
if _cmd and target_os_id:
|
||||||
|
_hint = "Missing build deps for this target: " + ", ".join(_missing)
|
||||||
|
pkg["install_cmd_for_target"] = _cmd
|
||||||
|
pkg["install_cmd_os"] = target_os_id
|
||||||
|
pkg["install_cmd_backend"] = (backend or "").lower()
|
||||||
|
else:
|
||||||
|
_hint = "Missing build deps: " + ", ".join(_missing) + ". Install via apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git."
|
||||||
|
_existing_note = pkg.get("status_note") or ""
|
||||||
|
pkg["status_note"] = (_existing_note + " — " + _hint) if _existing_note else _hint
|
||||||
|
pkg["build_deps_missing"] = _missing
|
||||||
|
|
||||||
if pkg.get("installed"):
|
if pkg.get("installed"):
|
||||||
update_status = _package_pip_update_status(pkg, probe)
|
update_status = _package_pip_update_status(pkg, probe)
|
||||||
pkg["pip_update_available"] = update_status.available
|
pkg["pip_update_available"] = update_status.available
|
||||||
@@ -1242,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
|
||||||
@@ -1277,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}"}
|
||||||
@@ -1289,6 +1613,127 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
return {"ok": True, "output": stdout.decode()[-200:]}
|
return {"ok": True, "output": stdout.decode()[-200:]}
|
||||||
return {"ok": False, "error": stderr.decode()[-300:]}
|
return {"ok": False, "error": stderr.decode()[-300:]}
|
||||||
|
|
||||||
|
@router.post("/api/cookbook/install-system-deps")
|
||||||
|
async def install_system_deps(request: Request):
|
||||||
|
"""Install OS-level system packages (cmake/build-essential/git/tmux)
|
||||||
|
on a remote target or in the local container. Admin only.
|
||||||
|
|
||||||
|
Bounded by a per-package allowlist — anything outside the catalog
|
||||||
|
is rejected so the route can't be coerced into installing arbitrary
|
||||||
|
OS packages. Uses `sudo -n` (passwordless) so the call returns a
|
||||||
|
clear "needs sudo password" error instead of hanging when interactive
|
||||||
|
sudo is required.
|
||||||
|
"""
|
||||||
|
_require_admin(request)
|
||||||
|
body = await request.json()
|
||||||
|
raw = body.get("packages") or []
|
||||||
|
host = (body.get("remote_host") or "").strip()
|
||||||
|
ssh_port = body.get("ssh_port")
|
||||||
|
# Names users can request — must match canonical names used in the
|
||||||
|
# deps catalog's `system_prereqs` field and on the System rows.
|
||||||
|
ALLOWED = {"cmake", "build-essential", "g++", "gcc", "git", "tmux", "make"}
|
||||||
|
pkgs = [str(p).strip() for p in raw if str(p).strip() in ALLOWED]
|
||||||
|
if not pkgs:
|
||||||
|
return {"ok": False, "error": "no installable packages requested (allowlist: " + ", ".join(sorted(ALLOWED)) + ")"}
|
||||||
|
# Re-map to the right package name per OS. apt/dpkg use the names
|
||||||
|
# as-is; pacman has base-devel for build-essential, etc.
|
||||||
|
def _apt(names): return list(names)
|
||||||
|
def _pacman(names):
|
||||||
|
return ["base-devel" if n == "build-essential" else n for n in names]
|
||||||
|
def _dnf(names):
|
||||||
|
out = []
|
||||||
|
for n in names:
|
||||||
|
if n == "build-essential": out += ["gcc", "gcc-c++", "make"]
|
||||||
|
elif n == "g++": out += ["gcc-c++"]
|
||||||
|
else: out.append(n)
|
||||||
|
return out
|
||||||
|
def _apk(names):
|
||||||
|
out = []
|
||||||
|
for n in names:
|
||||||
|
if n == "build-essential": out.append("build-base")
|
||||||
|
else: out.append(n)
|
||||||
|
return out
|
||||||
|
def _zypper(names):
|
||||||
|
out = []
|
||||||
|
for n in names:
|
||||||
|
if n == "build-essential": out += ["gcc-c++", "make"]
|
||||||
|
elif n == "g++": out.append("gcc-c++")
|
||||||
|
else: out.append(n)
|
||||||
|
return out
|
||||||
|
def _brew(names):
|
||||||
|
return [n for n in names if n not in ("build-essential", "g++", "gcc", "make")]
|
||||||
|
# Build a single shell snippet that detects the package manager and
|
||||||
|
# runs the right install. Non-interactive sudo (-n) only — if sudo
|
||||||
|
# asks for a password the script reports it instead of hanging.
|
||||||
|
apt_pkgs = " ".join(shlex.quote(p) for p in _apt(pkgs))
|
||||||
|
pac_pkgs = " ".join(shlex.quote(p) for p in _pacman(pkgs))
|
||||||
|
dnf_pkgs = " ".join(shlex.quote(p) for p in _dnf(pkgs))
|
||||||
|
apk_pkgs = " ".join(shlex.quote(p) for p in _apk(pkgs))
|
||||||
|
zypper_pkgs = " ".join(shlex.quote(p) for p in _zypper(pkgs))
|
||||||
|
brew_pkgs = " ".join(shlex.quote(p) for p in _brew(pkgs))
|
||||||
|
# Error messages go to stderr (>&2) so the route's error field
|
||||||
|
# gets populated. Without the redirect, `echo "ERROR…"` on stdout
|
||||||
|
# left stderr empty and the frontend toast fell through to a
|
||||||
|
# bare "HTTP 200" instead of surfacing the real reason.
|
||||||
|
script = (
|
||||||
|
'set -e; '
|
||||||
|
'BREW="$(command -v brew 2>/dev/null || true)"; '
|
||||||
|
'if [ -z "$BREW" ] && [ -x /opt/homebrew/bin/brew ]; then BREW=/opt/homebrew/bin/brew; fi; '
|
||||||
|
'if [ -z "$BREW" ] && [ -x /usr/local/bin/brew ]; then BREW=/usr/local/bin/brew; fi; '
|
||||||
|
'if [ -n "$BREW" ]; then '
|
||||||
|
f' if [ -z "{brew_pkgs}" ]; then echo "Nothing to install with brew for requested packages." >&2; exit 4; fi; "$BREW" install {brew_pkgs}; exit $?; '
|
||||||
|
'fi; '
|
||||||
|
'if [ "$(id -u)" = "0" ]; then SUDO=""; '
|
||||||
|
'elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then SUDO="sudo -n"; '
|
||||||
|
'else '
|
||||||
|
' echo "ERROR: this target needs sudo for its OS package manager, but passwordless sudo is unavailable. Open a terminal on the target and run the shown install command once, then retry in Cookbook." >&2; exit 2; fi; '
|
||||||
|
'if command -v apt-get >/dev/null 2>&1; then '
|
||||||
|
f' $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update -qq && $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends {apt_pkgs}; '
|
||||||
|
'elif command -v pacman >/dev/null 2>&1; then '
|
||||||
|
f' $SUDO pacman -Sy --needed --noconfirm {pac_pkgs}; '
|
||||||
|
'elif command -v dnf >/dev/null 2>&1; then '
|
||||||
|
f' $SUDO dnf install -y {dnf_pkgs}; '
|
||||||
|
'elif command -v apk >/dev/null 2>&1; then '
|
||||||
|
f' $SUDO apk add --no-interactive {apk_pkgs}; '
|
||||||
|
'elif command -v zypper >/dev/null 2>&1; then '
|
||||||
|
f' $SUDO zypper --non-interactive install {zypper_pkgs}; '
|
||||||
|
'else '
|
||||||
|
' echo "ERROR: no supported package manager (apt/pacman/dnf/apk/zypper/brew) on this target." >&2; exit 3; fi'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if host:
|
||||||
|
argv = _ssh_base_argv(host, ssh_port) + [script]
|
||||||
|
else:
|
||||||
|
argv = ["bash", "-lc", script]
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
out, err = await asyncio.wait_for(proc.communicate(), timeout=180)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"ok": False, "error": "Install timed out after 180s"}
|
||||||
|
ok = (proc.returncode == 0)
|
||||||
|
# Combine stderr + (last lines of stdout) into a single error
|
||||||
|
# blob when ok=False — some package managers print useful failure
|
||||||
|
# context to stdout, and a script that exits via `echo ...; exit N`
|
||||||
|
# without `>&2` would otherwise hand back an empty error string
|
||||||
|
# and force the frontend to show a bare "HTTP 200".
|
||||||
|
err_txt = err.decode("utf-8", errors="replace").strip()
|
||||||
|
out_txt = out.decode("utf-8", errors="replace").strip()
|
||||||
|
if not ok:
|
||||||
|
tail_out = out_txt[-500:] if out_txt else ""
|
||||||
|
combined = err_txt or tail_out or f"exit code {proc.returncode}"
|
||||||
|
else:
|
||||||
|
combined = None
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"exit_code": proc.returncode,
|
||||||
|
"output": out_txt[-1000:],
|
||||||
|
"error": combined,
|
||||||
|
}
|
||||||
|
|
||||||
@router.post("/api/cookbook/rebuild-engine")
|
@router.post("/api/cookbook/rebuild-engine")
|
||||||
async def rebuild_engine(request: Request):
|
async def rebuild_engine(request: Request):
|
||||||
"""Clear the cached llama.cpp build so the next serve recompiles.
|
"""Clear the cached llama.cpp build so the next serve recompiles.
|
||||||
@@ -1309,7 +1754,8 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
return {"ok": False, "error": f"Unsupported engine: {engine}"}
|
return {"ok": False, "error": f"Unsupported engine: {engine}"}
|
||||||
host = str(body.get("remote_host") or "").strip()
|
host = str(body.get("remote_host") or "").strip()
|
||||||
ssh_port = body.get("ssh_port")
|
ssh_port = body.get("ssh_port")
|
||||||
cmd = _llama_cpp_rebuild_cmd()
|
update_source = bool(body.get("update_source"))
|
||||||
|
cmd = _llama_cpp_rebuild_cmd(update_source=update_source)
|
||||||
try:
|
try:
|
||||||
argv = (
|
argv = (
|
||||||
(_ssh_base_argv(host, ssh_port) + [cmd])
|
(_ssh_base_argv(host, ssh_port) + [cmd])
|
||||||
|
|||||||
+11
-1
@@ -22,6 +22,16 @@ from core.middleware import require_admin
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Last-resort verdict extraction from a teacher/verifier model's prose (run when
|
||||||
|
# JSON parsing fails). `["\'\s:]*` already consumes whitespace, so the original
|
||||||
|
# trailing `\s*` made two adjacent \s-matching quantifiers that backtrack O(n^2)
|
||||||
|
# on a `verdict` + whitespace flood in untrusted model output (CodeQL
|
||||||
|
# py/polynomial-redos). Without it a single unbounded quantifier remains — the
|
||||||
|
# matched text is identical, and the scan is linear.
|
||||||
|
_VERDICT_PROSE_RE = re.compile(
|
||||||
|
r'verdict["\'\s:]*["\']?(pass|needs_work|fail|inconclusive)', re.I
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SkillAddRequest(BaseModel):
|
class SkillAddRequest(BaseModel):
|
||||||
# New schema (preferred)
|
# New schema (preferred)
|
||||||
@@ -196,7 +206,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
|||||||
# Last resort: pull the verdict keyword straight out of the prose so a
|
# Last resort: pull the verdict keyword straight out of the prose so a
|
||||||
# clearly-decided run isn't thrown away as "unparseable".
|
# clearly-decided run isn't thrown away as "unparseable".
|
||||||
if v not in _VERDICTS:
|
if v not in _VERDICTS:
|
||||||
km = _re.search(r'verdict["\'\s:]*\s*["\']?(pass|needs_work|fail|inconclusive)', text, _re.I)
|
km = _VERDICT_PROSE_RE.search(text)
|
||||||
if km:
|
if km:
|
||||||
v = km.group(1).lower()
|
v = km.group(1).lower()
|
||||||
if data is None:
|
if data is None:
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
+282
-21
@@ -3,18 +3,147 @@ import os
|
|||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
from fastapi import APIRouter, Request, File, UploadFile, HTTPException
|
import shutil
|
||||||
from typing import List
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Request, File, UploadFile, HTTPException, Form
|
||||||
|
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,
|
||||||
|
ChatMessage as DbChatMessage,
|
||||||
|
CalendarCal,
|
||||||
|
CalendarEvent,
|
||||||
|
Document,
|
||||||
|
DocumentVersion,
|
||||||
|
GalleryImage,
|
||||||
|
Note,
|
||||||
|
Session as DbSession,
|
||||||
|
)
|
||||||
from src.auth_helpers import effective_user
|
from src.auth_helpers import effective_user
|
||||||
from src.upload_handler import count_recent_uploads
|
from src.attachment_refs import attachment_refs_from_metadata
|
||||||
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
|
from src.upload_handler import (
|
||||||
|
UploadCleanupSafetyError,
|
||||||
|
count_recent_uploads,
|
||||||
|
extract_upload_ids,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/upload", tags=["upload"])
|
router = APIRouter(prefix="/api/upload", tags=["upload"])
|
||||||
UPLOAD_RESPONSE_HEADERS = {"X-Content-Type-Options": "nosniff"}
|
UPLOAD_RESPONSE_HEADERS = {"X-Content-Type-Options": "nosniff"}
|
||||||
|
|
||||||
|
def _upload_ids_from_persisted_text(value: object) -> set[str]:
|
||||||
|
"""Return canonical upload IDs embedded in persisted text.
|
||||||
|
|
||||||
|
This covers attachment reference lines/URIs and the PDF source markers
|
||||||
|
stored by the document editor. False positives are intentionally
|
||||||
|
conservative: retaining an extra upload is safer than deleting referenced
|
||||||
|
bytes.
|
||||||
|
"""
|
||||||
|
return extract_upload_ids(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_ids_from_message_metadata(raw_metadata: object) -> set[str]:
|
||||||
|
"""Extract attachment IDs from a persisted chat metadata JSON value.
|
||||||
|
|
||||||
|
Malformed metadata raises instead of being treated as an empty reference
|
||||||
|
set. The admin cleanup route catches that failure and aborts cleanup.
|
||||||
|
"""
|
||||||
|
if raw_metadata in (None, ""):
|
||||||
|
return set()
|
||||||
|
if isinstance(raw_metadata, str):
|
||||||
|
metadata = json.loads(raw_metadata)
|
||||||
|
else:
|
||||||
|
metadata = raw_metadata
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise ValueError("chat message metadata must be a JSON object")
|
||||||
|
|
||||||
|
attachments = metadata.get("attachments")
|
||||||
|
if attachments is not None:
|
||||||
|
if not isinstance(attachments, list) or any(
|
||||||
|
not isinstance(item, dict) for item in attachments
|
||||||
|
):
|
||||||
|
raise ValueError("chat message attachments metadata is malformed")
|
||||||
|
|
||||||
|
ids = {
|
||||||
|
str(ref["attachment_id"])
|
||||||
|
for ref in attachment_refs_from_metadata(metadata)
|
||||||
|
if ref.get("attachment_id")
|
||||||
|
}
|
||||||
|
# Preserve canonical IDs even in older metadata shapes not normalized by
|
||||||
|
# attachment_refs_from_metadata().
|
||||||
|
ids.update(_upload_ids_from_persisted_text(json.dumps(metadata)))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_persisted_upload_references() -> tuple[set[str], set[str]]:
|
||||||
|
"""Collect upload IDs/hashes still referenced by durable application data.
|
||||||
|
|
||||||
|
The caller must treat any exception as an incomplete scan and fail closed.
|
||||||
|
There is no distinct artifact table in the current schema; artifact-like
|
||||||
|
attachment references persisted in chat/document text are covered by the
|
||||||
|
canonical-ID scan.
|
||||||
|
"""
|
||||||
|
referenced_ids: set[str] = set()
|
||||||
|
referenced_hashes: set[str] = set()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for content, raw_metadata in db.query(
|
||||||
|
DbChatMessage.content,
|
||||||
|
DbChatMessage.meta_data,
|
||||||
|
).yield_per(500):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(content))
|
||||||
|
referenced_ids.update(_upload_ids_from_message_metadata(raw_metadata))
|
||||||
|
|
||||||
|
for (content,) in db.query(Document.current_content).yield_per(500):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(content))
|
||||||
|
|
||||||
|
for (content,) in db.query(DocumentVersion.content).yield_per(500):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(content))
|
||||||
|
|
||||||
|
for filename, file_hash in db.query(
|
||||||
|
GalleryImage.filename,
|
||||||
|
GalleryImage.file_hash,
|
||||||
|
).yield_per(500):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(filename))
|
||||||
|
if file_hash:
|
||||||
|
referenced_hashes.add(str(file_hash))
|
||||||
|
|
||||||
|
for image_url, color, content, items in db.query(
|
||||||
|
Note.image_url,
|
||||||
|
Note.color,
|
||||||
|
Note.content,
|
||||||
|
Note.items,
|
||||||
|
).yield_per(500):
|
||||||
|
for value in (image_url, color, content, items):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(value))
|
||||||
|
|
||||||
|
for (color,) in db.query(CalendarCal.color).yield_per(500):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(color))
|
||||||
|
|
||||||
|
for color, description, location in db.query(
|
||||||
|
CalendarEvent.color,
|
||||||
|
CalendarEvent.description,
|
||||||
|
CalendarEvent.location,
|
||||||
|
).yield_per(500):
|
||||||
|
for value in (color, description, location):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(value))
|
||||||
|
|
||||||
|
return referenced_ids, referenced_hashes
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_reference_safe_cleanup(upload_handler) -> int:
|
||||||
|
referenced_ids, referenced_hashes = _collect_persisted_upload_references()
|
||||||
|
return upload_handler.cleanup_old_uploads(
|
||||||
|
referenced_upload_ids=referenced_ids,
|
||||||
|
referenced_upload_hashes=referenced_hashes,
|
||||||
|
)
|
||||||
|
|
||||||
def setup_upload_routes(upload_handler):
|
def setup_upload_routes(upload_handler):
|
||||||
"""Setup upload routes with the provided handler"""
|
"""Setup upload routes with the provided handler"""
|
||||||
|
|
||||||
@@ -51,9 +180,89 @@ def setup_upload_routes(upload_handler):
|
|||||||
|
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
|
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."""
|
||||||
|
is_image_file = getattr(upload_handler, "is_image_file", None)
|
||||||
|
if not callable(is_image_file):
|
||||||
|
return None
|
||||||
|
if not is_image_file(meta.get("name", ""), meta.get("mime", "")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
source_path = meta.get("path")
|
||||||
|
if not source_path or not os.path.isfile(source_path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
file_hash = meta.get("hash")
|
||||||
|
if file_hash:
|
||||||
|
q = db.query(GalleryImage).filter(
|
||||||
|
GalleryImage.file_hash == file_hash,
|
||||||
|
GalleryImage.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
if owner:
|
||||||
|
q = q.filter(GalleryImage.owner == owner)
|
||||||
|
existing = q.first()
|
||||||
|
if existing:
|
||||||
|
return existing.id
|
||||||
|
|
||||||
|
image_dir = Path(GENERATED_IMAGES_DIR)
|
||||||
|
image_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ext = Path(meta.get("name") or source_path).suffix.lower()
|
||||||
|
if ext not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
|
||||||
|
mime_ext = {
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/jpg": ".jpg",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
}.get(meta.get("mime", ""))
|
||||||
|
ext = mime_ext or ".png"
|
||||||
|
filename = f"{uuid.uuid4().hex[:12]}{ext}"
|
||||||
|
dest_path = image_dir / filename
|
||||||
|
shutil.copy2(source_path, dest_path)
|
||||||
|
|
||||||
|
image_id = str(uuid.uuid4())
|
||||||
|
db.add(GalleryImage(
|
||||||
|
id=image_id,
|
||||||
|
filename=filename,
|
||||||
|
prompt=meta.get("name") or "Chat upload",
|
||||||
|
model="chat-upload",
|
||||||
|
owner=owner,
|
||||||
|
session_id=_valid_session_id_for_owner(db, session_id, owner),
|
||||||
|
file_hash=file_hash,
|
||||||
|
width=meta.get("width"),
|
||||||
|
height=meta.get("height"),
|
||||||
|
file_size=meta.get("size"),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
return image_id
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.warning("Failed to add chat image upload to gallery: %s", e)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
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")
|
||||||
|
|
||||||
@@ -78,18 +287,25 @@ def setup_upload_routes(upload_handler):
|
|||||||
|
|
||||||
for u in files:
|
for u in files:
|
||||||
try:
|
try:
|
||||||
meta = upload_handler.save_upload(u, client_ip, owner=effective_user(request))
|
owner = effective_user(request)
|
||||||
out.append({
|
meta = upload_handler.save_upload(u, client_ip, owner=owner)
|
||||||
|
gallery_id = _promote_chat_image_to_gallery(meta, owner, session_id)
|
||||||
|
item = {
|
||||||
"id": meta["id"],
|
"id": meta["id"],
|
||||||
"name": meta["name"],
|
"name": meta["name"],
|
||||||
"mime": meta["mime"],
|
"mime": meta["mime"],
|
||||||
"size": meta["size"],
|
"size": meta["size"],
|
||||||
"hash": meta["hash"],
|
"hash": meta["hash"],
|
||||||
|
"checksum_sha256": meta.get("checksum_sha256") or meta["hash"],
|
||||||
"uploaded_at": meta["uploaded_at"],
|
"uploaded_at": meta["uploaded_at"],
|
||||||
|
"created_at": meta.get("created_at") or meta["uploaded_at"],
|
||||||
"width": meta.get("width"),
|
"width": meta.get("width"),
|
||||||
"height": meta.get("height"),
|
"height": meta.get("height"),
|
||||||
"is_duplicate": meta.get("is_duplicate", False)
|
"is_duplicate": meta.get("is_duplicate", False)
|
||||||
})
|
}
|
||||||
|
if gallery_id:
|
||||||
|
item["gallery_id"] = gallery_id
|
||||||
|
out.append(item)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -105,7 +321,23 @@ def setup_upload_routes(upload_handler):
|
|||||||
async def manual_cleanup(request: Request):
|
async def manual_cleanup(request: Request):
|
||||||
"""Manually trigger cleanup of old uploads."""
|
"""Manually trigger cleanup of old uploads."""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
cleaned_count = upload_handler.cleanup_old_uploads()
|
try:
|
||||||
|
cleaned_count = await asyncio.to_thread(
|
||||||
|
_run_reference_safe_cleanup,
|
||||||
|
upload_handler,
|
||||||
|
)
|
||||||
|
except UploadCleanupSafetyError:
|
||||||
|
logger.exception("Upload cleanup aborted because index safety checks failed")
|
||||||
|
raise HTTPException(
|
||||||
|
503,
|
||||||
|
"Upload cleanup aborted because upload index integrity could not be verified",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Upload cleanup skipped because reference discovery failed")
|
||||||
|
raise HTTPException(
|
||||||
|
503,
|
||||||
|
"Upload cleanup skipped because persisted references could not be verified",
|
||||||
|
)
|
||||||
return {"status": "success", "files_cleaned": cleaned_count}
|
return {"status": "success", "files_cleaned": cleaned_count}
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
@@ -128,11 +360,10 @@ def setup_upload_routes(upload_handler):
|
|||||||
import mimetypes as _mt
|
import mimetypes as _mt
|
||||||
# Look up original filename and owner from uploads.json
|
# Look up original filename and owner from uploads.json
|
||||||
original_name = file_id
|
original_name = file_id
|
||||||
info = None
|
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
|
||||||
uploads_db = os.path.join(_upload_root(), "uploads.json")
|
# back to the .bak sibling, then to {}), so a truncated DB degrades to
|
||||||
if os.path.exists(uploads_db):
|
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
|
||||||
with open(uploads_db, encoding="utf-8") as f:
|
db = upload_handler._load_upload_index()
|
||||||
db = json.load(f)
|
|
||||||
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||||
if info:
|
if info:
|
||||||
original_name = info.get("name", file_id)
|
original_name = info.get("name", file_id)
|
||||||
@@ -181,19 +412,42 @@ def setup_upload_routes(upload_handler):
|
|||||||
|
|
||||||
def _load_upload_info(file_id: str):
|
def _load_upload_info(file_id: str):
|
||||||
"""Look up the uploads.json record for a file_id, with owner/auth checks."""
|
"""Look up the uploads.json record for a file_id, with owner/auth checks."""
|
||||||
info = None
|
# Corruption-tolerant load (see download_file): a bad uploads.json yields
|
||||||
uploads_db = os.path.join(_upload_root(), "uploads.json")
|
# {} rather than raising JSONDecodeError out of the vision path.
|
||||||
if os.path.exists(uploads_db):
|
db = upload_handler._load_upload_index()
|
||||||
with open(uploads_db, encoding="utf-8") as f:
|
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||||
db = json.load(f)
|
|
||||||
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
|
||||||
return info
|
|
||||||
|
|
||||||
def _vision_cache_path(file_id: str) -> str:
|
def _vision_cache_path(file_id: str) -> str:
|
||||||
cache_dir = os.path.join(_upload_root(), ".vision")
|
cache_dir = os.path.join(_upload_root(), ".vision")
|
||||||
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.
|
||||||
@@ -220,7 +474,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
|
||||||
@@ -234,6 +490,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")
|
||||||
@@ -255,12 +512,16 @@ def setup_upload_routes(upload_handler):
|
|||||||
if file_owner != current_user and not auth_mgr.is_admin(current_user):
|
if file_owner != current_user and not auth_mgr.is_admin(current_user):
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
_resolve_upload_path(file_id)
|
_resolve_upload_path(file_id)
|
||||||
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(400, "Request body must be valid JSON")
|
||||||
text = (body or {}).get("text", "")
|
text = (body or {}).get("text", "")
|
||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
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():
|
||||||
|
|||||||
@@ -345,8 +345,9 @@ def setup_webhook_routes(
|
|||||||
resp = await client.get(models_url, headers=hdrs)
|
resp = await client.get(models_url, headers=hdrs)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
items = data if isinstance(data, list) else (data.get("data") or [])
|
||||||
if not ids:
|
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||||
|
if not ids and isinstance(data, dict):
|
||||||
ids = [
|
ids = [
|
||||||
m.get("name") or m.get("model")
|
m.get("name") or m.get("model")
|
||||||
for m in (data.get("models") or [])
|
for m in (data.get("models") or [])
|
||||||
|
|||||||
@@ -27,12 +27,18 @@ def claim_json_entries(entries, owner):
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def owner_arg(argv):
|
||||||
|
if len(argv) < 2 or not argv[1].strip():
|
||||||
|
return None
|
||||||
|
return argv[1].strip()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if len(sys.argv) < 2:
|
owner = owner_arg(sys.argv)
|
||||||
|
if not owner:
|
||||||
print("Usage: python scripts/claim_ownerless.py <username>")
|
print("Usage: python scripts/claim_ownerless.py <username>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
owner = sys.argv[1]
|
|
||||||
print(f"Claiming all ownerless data for: {owner}\n")
|
print(f"Claiming all ownerless data for: {owner}\n")
|
||||||
|
|
||||||
# 1. Memories (JSON files)
|
# 1. Memories (JSON files)
|
||||||
|
|||||||
+21
-7
@@ -133,16 +133,30 @@ def cmd_list(args):
|
|||||||
emit([], args)
|
emit([], args)
|
||||||
return
|
return
|
||||||
entries = []
|
entries = []
|
||||||
for p in sorted(_BACKUP_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
for p in _BACKUP_DIR.iterdir():
|
||||||
|
entry = _backup_entry(p)
|
||||||
|
if entry is not None:
|
||||||
|
entries.append(entry)
|
||||||
|
entries.sort(key=lambda entry: entry["_mtime"], reverse=True)
|
||||||
|
for entry in entries:
|
||||||
|
entry.pop("_mtime", None)
|
||||||
|
emit(entries, args)
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_entry(p):
|
||||||
|
try:
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
continue
|
return None
|
||||||
entries.append({
|
st = p.stat()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
"path": str(p),
|
"path": str(p),
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"bytes": p.stat().st_size,
|
"bytes": st.st_size,
|
||||||
"modified": datetime.fromtimestamp(p.stat().st_mtime).isoformat(),
|
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
||||||
})
|
"_mtime": st.st_mtime,
|
||||||
emit(entries, args)
|
}
|
||||||
|
|
||||||
|
|
||||||
def cmd_verify(args):
|
def cmd_verify(args):
|
||||||
|
|||||||
+15
-11
@@ -38,23 +38,27 @@ def _preview_text(value, limit: int = 200) -> str:
|
|||||||
return text[:limit]
|
return text[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _text_field(value) -> str:
|
||||||
|
return value if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
def _serialize_image(i: "GalleryImage") -> dict:
|
def _serialize_image(i: "GalleryImage") -> dict:
|
||||||
return {
|
return {
|
||||||
"id": i.id,
|
"id": i.id,
|
||||||
"filename": i.filename,
|
"filename": _text_field(i.filename),
|
||||||
"prompt": _preview_text(i.prompt),
|
"prompt": _preview_text(i.prompt),
|
||||||
"model": i.model or "",
|
"model": _text_field(i.model),
|
||||||
"size": i.size or "",
|
"size": _text_field(i.size),
|
||||||
"tags": i.tags or "",
|
"tags": _text_field(i.tags),
|
||||||
"favorite": bool(i.favorite),
|
"favorite": bool(i.favorite),
|
||||||
"album_id": i.album_id or "",
|
"album_id": _text_field(i.album_id),
|
||||||
"session_id": i.session_id or "",
|
"session_id": _text_field(i.session_id),
|
||||||
"width": i.width,
|
"width": i.width,
|
||||||
"height": i.height,
|
"height": i.height,
|
||||||
"file_size": i.file_size,
|
"file_size": i.file_size,
|
||||||
"taken_at": i.taken_at.isoformat() if i.taken_at else "",
|
"taken_at": i.taken_at.isoformat() if i.taken_at else "",
|
||||||
"camera_make": i.camera_make or "",
|
"camera_make": _text_field(i.camera_make),
|
||||||
"camera_model": i.camera_model or "",
|
"camera_model": _text_field(i.camera_model),
|
||||||
"created_at": i.created_at.isoformat() if i.created_at else "",
|
"created_at": i.created_at.isoformat() if i.created_at else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +97,11 @@ def cmd_show(args):
|
|||||||
if not i:
|
if not i:
|
||||||
fail(f"no image with id {args.id!r}")
|
fail(f"no image with id {args.id!r}")
|
||||||
out = _serialize_image(i)
|
out = _serialize_image(i)
|
||||||
out["prompt_full"] = i.prompt or ""
|
out["prompt_full"] = _text_field(i.prompt)
|
||||||
out["ai_tags"] = i.ai_tags or ""
|
out["ai_tags"] = _text_field(i.ai_tags)
|
||||||
out["gps_lat"] = i.gps_lat or ""
|
out["gps_lat"] = i.gps_lat or ""
|
||||||
out["gps_lng"] = i.gps_lng or ""
|
out["gps_lng"] = i.gps_lng or ""
|
||||||
out["file_hash"] = i.file_hash or ""
|
out["file_hash"] = _text_field(i.file_hash)
|
||||||
emit(out, args)
|
emit(out, args)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ def _q(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _split_recipients(value: str) -> list[str]:
|
def _split_recipients(value: str) -> list[str]:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return []
|
||||||
return [r.strip() for r in (value or "").split(",") if r.strip()]
|
return [r.strip() for r in (value or "").split(",") if r.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ def cmd_add(args):
|
|||||||
# add_entry doesn't save by default — the call in chat does it
|
# add_entry doesn't save by default — the call in chat does it
|
||||||
# after dedup checks. Persist here so a one-shot CLI add sticks.
|
# after dedup checks. Persist here so a one-shot CLI add sticks.
|
||||||
all_entries = _manager().load_all()
|
all_entries = _manager().load_all()
|
||||||
if not any(e.get("id") == entry.get("id") for e in all_entries):
|
if not any(isinstance(e, dict) and e.get("id") == entry.get("id") for e in all_entries):
|
||||||
all_entries.append(entry)
|
all_entries.append(entry)
|
||||||
_manager().save(all_entries)
|
_manager().save(all_entries)
|
||||||
emit(entry, args)
|
emit(entry, args)
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ def _load_items(raw) -> list:
|
|||||||
items = json.loads(raw)
|
items = json.loads(raw)
|
||||||
except (TypeError, json.JSONDecodeError):
|
except (TypeError, json.JSONDecodeError):
|
||||||
return []
|
return []
|
||||||
return items if isinstance(items, list) else []
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
return [item for item in items if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
def _serialize(n: "Note") -> dict:
|
def _serialize(n: "Note") -> dict:
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -14059,6 +14209,138 @@
|
|||||||
"vision"
|
"vision"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "google/gemma-4-12B-it",
|
||||||
|
"provider": "Google",
|
||||||
|
"parameter_count": "12.0B",
|
||||||
|
"parameters_raw": 12000000000,
|
||||||
|
"min_ram_gb": 8.5,
|
||||||
|
"recommended_ram_gb": 11.0,
|
||||||
|
"min_vram_gb": 7.5,
|
||||||
|
"quantization": "Q4_K_M",
|
||||||
|
"context_length": 131072,
|
||||||
|
"use_case": "General purpose, multimodal; unsloth/gemma-4-12B-it-GGUF Dynamic variants reduce VRAM from ~7.5 GB to ~5.5 GB",
|
||||||
|
"is_moe": false,
|
||||||
|
"num_experts": null,
|
||||||
|
"active_experts": null,
|
||||||
|
"active_parameters": null,
|
||||||
|
"architecture": "gemma4",
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"release_date": "2026-04-01",
|
||||||
|
"gguf_sources": [
|
||||||
|
{
|
||||||
|
"repo": "unsloth/gemma-4-12B-it-GGUF",
|
||||||
|
"provider": "unsloth"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
"vision"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "google/gemma-4-12B-it-qat-int4",
|
||||||
|
"provider": "Google",
|
||||||
|
"parameter_count": "12.0B",
|
||||||
|
"parameters_raw": 12000000000,
|
||||||
|
"min_ram_gb": 8.0,
|
||||||
|
"recommended_ram_gb": 9.5,
|
||||||
|
"min_vram_gb": 6.5,
|
||||||
|
"quantization": "QAT-INT4",
|
||||||
|
"context_length": 131072,
|
||||||
|
"use_case": "General purpose, multimodal (QAT quantization-aware training — higher quality than post-train INT4; vLLM native; no GGUF)",
|
||||||
|
"is_moe": false,
|
||||||
|
"num_experts": null,
|
||||||
|
"active_experts": null,
|
||||||
|
"active_parameters": null,
|
||||||
|
"architecture": "gemma4",
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"release_date": "2026-04-01",
|
||||||
|
"gguf_sources": [],
|
||||||
|
"capabilities": [
|
||||||
|
"vision"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "google/gemma-4-12B-it-qat-int8",
|
||||||
|
"provider": "Google",
|
||||||
|
"parameter_count": "12.0B",
|
||||||
|
"parameters_raw": 12000000000,
|
||||||
|
"min_ram_gb": 15.0,
|
||||||
|
"recommended_ram_gb": 20.0,
|
||||||
|
"min_vram_gb": 13.5,
|
||||||
|
"quantization": "QAT-INT8",
|
||||||
|
"context_length": 131072,
|
||||||
|
"use_case": "General purpose, multimodal (QAT INT8 — highest quality, 2x VRAM of QAT-INT4; vLLM native; no GGUF)",
|
||||||
|
"is_moe": false,
|
||||||
|
"num_experts": null,
|
||||||
|
"active_experts": null,
|
||||||
|
"active_parameters": null,
|
||||||
|
"architecture": "gemma4",
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"release_date": "2026-04-01",
|
||||||
|
"gguf_sources": [],
|
||||||
|
"capabilities": [
|
||||||
|
"vision"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "google/gemma-4-12B-it-qat-q4_0-gguf",
|
||||||
|
"provider": "Google",
|
||||||
|
"parameter_count": "12.0B",
|
||||||
|
"parameters_raw": 12000000000,
|
||||||
|
"min_ram_gb": 8.5,
|
||||||
|
"recommended_ram_gb": 11.0,
|
||||||
|
"min_vram_gb": 7.5,
|
||||||
|
"quantization": "QAT-INT4",
|
||||||
|
"context_length": 262144,
|
||||||
|
"use_case": "General purpose, multimodal (vision + audio); official Google QAT int4 GGUF — near-bf16 quality at int4 size, served on llama.cpp/Ollama with CPU offload",
|
||||||
|
"is_moe": false,
|
||||||
|
"num_experts": null,
|
||||||
|
"active_experts": null,
|
||||||
|
"active_parameters": null,
|
||||||
|
"architecture": "gemma4",
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"release_date": "2026-04-01",
|
||||||
|
"gguf_sources": [
|
||||||
|
{
|
||||||
|
"repo": "google/gemma-4-12B-it-qat-q4_0-gguf",
|
||||||
|
"provider": "Google",
|
||||||
|
"file": "gemma-4-12b-it-qat-q4_0.gguf"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
"vision",
|
||||||
|
"audio"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "google/gemma-4-26B-A4B-it-qat-q4_0-gguf",
|
||||||
|
"provider": "Google",
|
||||||
|
"parameter_count": "25.2B",
|
||||||
|
"parameters_raw": 25200000000,
|
||||||
|
"min_ram_gb": 14.4,
|
||||||
|
"recommended_ram_gb": 18.0,
|
||||||
|
"min_vram_gb": 14.4,
|
||||||
|
"quantization": "QAT-INT4",
|
||||||
|
"context_length": 262144,
|
||||||
|
"use_case": "High-throughput, multimodal MoE (3.8B active); official Google QAT int4 GGUF — near-bf16 quality at int4 size, served on llama.cpp with CPU offload",
|
||||||
|
"is_moe": true,
|
||||||
|
"num_experts": null,
|
||||||
|
"active_experts": null,
|
||||||
|
"active_parameters": 3800000000,
|
||||||
|
"architecture": "gemma4",
|
||||||
|
"pipeline_tag": "image-text-to-text",
|
||||||
|
"release_date": "2026-04-01",
|
||||||
|
"gguf_sources": [
|
||||||
|
{
|
||||||
|
"repo": "google/gemma-4-26B-A4B-it-qat-q4_0-gguf",
|
||||||
|
"provider": "Google"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
"vision"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "google/gemma-4-31B-it",
|
"name": "google/gemma-4-31B-it",
|
||||||
"provider": "Google",
|
"provider": "Google",
|
||||||
@@ -18823,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
+112
-14
@@ -9,7 +9,7 @@ from services.hwfit.models import (
|
|||||||
GPU_BANDWIDTH = {
|
GPU_BANDWIDTH = {
|
||||||
"5090": 1792, "5080": 960, "5070 ti": 896, "5070": 672, "5060 ti": 448, "5060": 256,
|
"5090": 1792, "5080": 960, "5070 ti": 896, "5070": 672, "5060 ti": 448, "5060": 256,
|
||||||
"4090": 1008, "4080 super": 736, "4080": 717, "4070 ti super": 672, "4070 ti": 504, "4070 super": 504, "4070": 504, "4060 ti": 288, "4060": 272,
|
"4090": 1008, "4080 super": 736, "4080": 717, "4070 ti super": 672, "4070 ti": 504, "4070 super": 504, "4070": 504, "4060 ti": 288, "4060": 272,
|
||||||
"3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360,
|
"3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360, "3050 ti": 192, "3050": 224,
|
||||||
"2080 ti": 616, "2080 super": 496, "2080": 448, "2070 super": 448, "2070": 448, "2060 super": 448, "2060": 336,
|
"2080 ti": 616, "2080 super": 496, "2080": 448, "2070 super": 448, "2070": 448, "2060 super": 448, "2060": 336,
|
||||||
"1660 ti": 288, "1660 super": 336, "1660": 192, "1650 super": 192, "1650": 128,
|
"1660 ti": 288, "1660 super": 336, "1660": 192, "1650 super": 192, "1650": 128,
|
||||||
"h100 sxm": 3350, "h100": 2039, "h200": 4800, "a100 sxm": 2039, "a100": 1555,
|
"h100 sxm": 3350, "h100": 2039, "h200": 4800, "a100 sxm": 2039, "a100": 1555,
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -282,7 +282,17 @@ def _detect_amd():
|
|||||||
"gpus": cards,
|
"gpus": cards,
|
||||||
"gpu_groups": groups,
|
"gpu_groups": groups,
|
||||||
"homogeneous": len(groups) <= 1,
|
"homogeneous": len(groups) <= 1,
|
||||||
"backend": "rocm",
|
# Pick the actual runtime label: ROCm/HIP only when its
|
||||||
|
# toolchain is installed, otherwise Vulkan if vulkaninfo is
|
||||||
|
# present (mesa RADV works fine on RDNA/CDNA when ROCm
|
||||||
|
# packages are absent — see Strix Halo where ROCm support
|
||||||
|
# is still backporting). Reporting "rocm" on a Vulkan-only
|
||||||
|
# host misleads downstream env-var pinning
|
||||||
|
# (HIP_VISIBLE_DEVICES is a no-op there).
|
||||||
|
"backend": (
|
||||||
|
"rocm" if (_run(["which", "rocminfo"]) or _run(["which", "hipconfig"]))
|
||||||
|
else ("vulkan" if _run(["which", "vulkaninfo"]) else "rocm")
|
||||||
|
),
|
||||||
"unified_memory": is_apu,
|
"unified_memory": is_apu,
|
||||||
# AMD ISA/family so downstream can tell datacenter Instinct (CDNA,
|
# AMD ISA/family so downstream can tell datacenter Instinct (CDNA,
|
||||||
# where vLLM/SGLang run AWQ/GPTQ reliably) from consumer Radeon
|
# where vLLM/SGLang run AWQ/GPTQ reliably) from consumer Radeon
|
||||||
@@ -528,6 +538,32 @@ def _powershell_exe():
|
|||||||
path so we don't depend on a particular PATH ordering."""
|
path so we don't depend on a particular PATH ordering."""
|
||||||
return shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
return shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
||||||
|
|
||||||
|
def _powershell_encoded_for_ssh(script: str):
|
||||||
|
"""Run a PowerShell script on a remote Windows host over SSH.
|
||||||
|
|
||||||
|
Nested quotes in powershell -Command break when passed through Windows
|
||||||
|
OpenSSH's cmd wrapper; -EncodedCommand avoids that.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||||
|
return _run(f"powershell -NoProfile -EncodedCommand {encoded}")
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_remote_platform():
|
||||||
|
"""Best-effort OS detection over SSH when the caller didn't pass platform."""
|
||||||
|
out = _run("echo %OS%")
|
||||||
|
if out and "Windows_NT" in out:
|
||||||
|
return "windows"
|
||||||
|
uname = (_run(["uname", "-s"]) or "").strip().lower()
|
||||||
|
if uname == "darwin":
|
||||||
|
# Mac uses the linux detection path (_detect_apple_silicon over SSH).
|
||||||
|
return "linux"
|
||||||
|
if uname == "linux":
|
||||||
|
out = _run("test -d /data/data/com.termux && echo termux || echo linux")
|
||||||
|
if out and "termux" in out:
|
||||||
|
return "termux"
|
||||||
|
return "linux"
|
||||||
|
|
||||||
|
|
||||||
def _detect_windows():
|
def _detect_windows():
|
||||||
"""Detect Windows hardware via PowerShell/WMI.
|
"""Detect Windows hardware via PowerShell/WMI.
|
||||||
@@ -590,9 +626,8 @@ def _detect_windows():
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
if _remote_host:
|
if _remote_host:
|
||||||
# Remote: ship a single command string over SSH. The remote shell parses
|
# Remote: use -EncodedCommand so OpenSSH/cmd quoting does not break the script.
|
||||||
# the quoting; PowerShell on the far side runs the -Command payload.
|
out = _powershell_encoded_for_ssh(ps_cmd.strip())
|
||||||
out = _run(f'powershell -Command "{ps_cmd}"')
|
|
||||||
else:
|
else:
|
||||||
# Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd
|
# Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd
|
||||||
# to PowerShell verbatim — no fragile string-level quote escaping. Prefer
|
# to PowerShell verbatim — no fragile string-level quote escaping. Prefer
|
||||||
@@ -763,6 +798,13 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
"""
|
"""
|
||||||
global _remote_host, _remote_port, _remote_platform
|
global _remote_host, _remote_port, _remote_platform
|
||||||
|
|
||||||
|
if host and not platform:
|
||||||
|
_remote_host = host
|
||||||
|
_remote_port = ssh_port or None
|
||||||
|
platform = _probe_remote_platform()
|
||||||
|
_remote_host = None
|
||||||
|
_remote_port = None
|
||||||
|
|
||||||
cache_key = _cache_key(host, ssh_port, platform)
|
cache_key = _cache_key(host, ssh_port, platform)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if not fresh and cache_key in _cache_by_host:
|
if not fresh and cache_key in _cache_by_host:
|
||||||
@@ -783,8 +825,8 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
_remote_platform = None
|
_remote_platform = None
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
return result
|
return result
|
||||||
# If Windows detection failed, return error
|
# SSH may work while the PowerShell hardware probe still fails.
|
||||||
result = {"error": f"Cannot connect to {host}", "host": host}
|
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
|
||||||
_remote_host = None
|
_remote_host = None
|
||||||
_remote_platform = None
|
_remote_platform = None
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
|
|||||||
@@ -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()
|
||||||
+87
-12
@@ -12,7 +12,8 @@ QUANT_BPP = {
|
|||||||
"Q4_K_M": 0.58, "Q4_0": 0.58, "Q3_K_M": 0.48, "Q2_K": 0.37,
|
"Q4_K_M": 0.58, "Q4_0": 0.58, "Q3_K_M": 0.48, "Q2_K": 0.37,
|
||||||
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
|
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
|
||||||
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
|
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
|
||||||
"mlx-4bit": 0.55, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
|
"QAT-INT4": 0.50, "QAT-INT8": 1.0,
|
||||||
|
"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.
|
||||||
@@ -30,7 +31,8 @@ QUANT_SPEED_MULT = {
|
|||||||
"Q4_K_M": 1.15, "Q4_0": 1.15, "Q3_K_M": 1.25, "Q2_K": 1.35,
|
"Q4_K_M": 1.15, "Q4_0": 1.15, "Q3_K_M": 1.25, "Q2_K": 1.35,
|
||||||
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
|
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
|
||||||
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
|
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
|
||||||
"mlx-4bit": 1.15, "mlx-8bit": 0.85, "mlx-6bit": 1.0,
|
"QAT-INT4": 1.15, "QAT-INT8": 0.85,
|
||||||
|
"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,
|
||||||
}
|
}
|
||||||
@@ -47,7 +49,11 @@ QUANT_QUALITY_PENALTY = {
|
|||||||
# penalty so FP8 wins when both fit. AWQ-4bit stays heavier.
|
# penalty so FP8 wins when both fit. AWQ-4bit stays heavier.
|
||||||
"AWQ": -1.0, "AWQ-4bit": -4.0, "AWQ-8bit": -1.0,
|
"AWQ": -1.0, "AWQ-4bit": -4.0, "AWQ-8bit": -1.0,
|
||||||
"GPTQ": -1.0, "GPTQ-Int4": -4.0, "GPTQ-Int8": -1.0,
|
"GPTQ": -1.0, "GPTQ-Int4": -4.0, "GPTQ-Int8": -1.0,
|
||||||
"mlx-4bit": -4.0, "mlx-8bit": -0.5, "mlx-6bit": -1.5,
|
# Quantization-aware training recovers most of the int4 quality loss, so a
|
||||||
|
# QAT-INT4 build lands far closer to bf16 than a post-training Q4/INT4
|
||||||
|
# (Google reports near-bf16 quality). Penalize it lightly, not like Q4_K_M.
|
||||||
|
"QAT-INT4": -1.0, "QAT-INT8": 0.0,
|
||||||
|
"mlx-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.
|
||||||
@@ -63,7 +69,8 @@ QUANT_BYTES_PER_PARAM = {
|
|||||||
"Q4_K_M": 0.5, "Q4_0": 0.5, "Q3_K_M": 0.375, "Q2_K": 0.25,
|
"Q4_K_M": 0.5, "Q4_0": 0.5, "Q3_K_M": 0.375, "Q2_K": 0.25,
|
||||||
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
|
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
|
||||||
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
|
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
|
||||||
"mlx-4bit": 0.5, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
|
"QAT-INT4": 0.5, "QAT-INT8": 1.0,
|
||||||
|
"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,
|
||||||
}
|
}
|
||||||
@@ -74,13 +81,17 @@ PREQUANTIZED_PREFIXES = (
|
|||||||
"AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
|
"AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
|
||||||
"INT4", "INT8", "W4A16", "W8A8", "W8A16",
|
"INT4", "INT8", "W4A16", "W8A8", "W8A16",
|
||||||
"FP4-MoE-Mixed", "FP8-Mixed",
|
"FP4-MoE-Mixed", "FP8-Mixed",
|
||||||
|
"QAT-",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
@@ -98,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:
|
||||||
@@ -130,7 +145,7 @@ def is_prequantized(model):
|
|||||||
or re.search(r"(^|[-_/])fp8($|[-_/\s])", text) is not None
|
or re.search(r"(^|[-_/])fp8($|[-_/\s])", text) is not None
|
||||||
or (not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text) is not None)
|
or (not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text) is not None)
|
||||||
or any(x in text for x in ("awq", "gptq", "mlx"))
|
or any(x in text for x in ("awq", "gptq", "mlx"))
|
||||||
or any(q.startswith(p) for p in PREQUANTIZED_PREFIXES)
|
or any(isinstance(q, str) and q.startswith(p) for p in PREQUANTIZED_PREFIXES)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -140,7 +155,7 @@ def params_b(model):
|
|||||||
return raw / 1_000_000_000.0
|
return raw / 1_000_000_000.0
|
||||||
|
|
||||||
pc = model.get("parameter_count", "")
|
pc = model.get("parameter_count", "")
|
||||||
if pc:
|
if isinstance(pc, str) and pc:
|
||||||
pc = pc.strip().upper()
|
pc = pc.strip().upper()
|
||||||
m = re.match(r"^([\d.]+)\s*([BKMGT]?)$", pc)
|
m = re.match(r"^([\d.]+)\s*([BKMGT]?)$", pc)
|
||||||
if m:
|
if m:
|
||||||
@@ -252,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ def compute_serve_profiles(system, model, serve_weights_gb=None, serve_quant=Non
|
|||||||
in the actual serving knobs (n_cpu_moe, KV-cache type, context). serve_quant
|
in the actual serving knobs (n_cpu_moe, KV-cache type, context). serve_quant
|
||||||
is the file's quant label (e.g. "Q4_K_M") just for display.
|
is the file's quant label (e.g. "Q4_K_M") just for display.
|
||||||
"""
|
"""
|
||||||
|
if not isinstance(system, dict) or not isinstance(model, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
vram = float(system.get("gpu_vram_gb") or 0)
|
vram = float(system.get("gpu_vram_gb") or 0)
|
||||||
if vram <= 0:
|
if vram <= 0:
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -68,6 +68,52 @@ def _is_text_file(name: str) -> bool:
|
|||||||
return any(low.endswith(s) for s in ALLOWED_SUFFIXES)
|
return any(low.endswith(s) for s in ALLOWED_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
# Max redirect hops to follow manually while re-validating each one.
|
||||||
|
_MAX_FETCH_REDIRECTS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _check_fetch_url(url: str) -> None:
|
||||||
|
"""SSRF guard for skill-import fetches (defense-in-depth).
|
||||||
|
|
||||||
|
Skill bundles only ever come from public GitHub, never an internal
|
||||||
|
address, so block private/loopback/link-local targets on every hop —
|
||||||
|
matching the hardened web-fetch path in
|
||||||
|
``services/search/content.py:_get_public_url`` rather than the lenient
|
||||||
|
default used for admin-configured model endpoints.
|
||||||
|
"""
|
||||||
|
ok, reason = check_outbound_url(url, block_private=True)
|
||||||
|
if not ok:
|
||||||
|
raise SkillImportError(reason)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_checked(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: Optional[dict] = None,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""GET that follows redirects manually, re-running the SSRF guard per hop.
|
||||||
|
|
||||||
|
``httpx``'s ``follow_redirects=True`` validates only the initial URL, so a
|
||||||
|
``3xx`` to an internal address (``169.254.169.254``, ``127.0.0.1``, …) would
|
||||||
|
still be connected to before any post-hoc host check. Following redirects by
|
||||||
|
hand lets us re-validate every hop, closing that blind-SSRF gap.
|
||||||
|
"""
|
||||||
|
current = url
|
||||||
|
with httpx.Client(follow_redirects=False, timeout=timeout) as client:
|
||||||
|
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
||||||
|
_check_fetch_url(current)
|
||||||
|
r = client.get(current, headers=headers)
|
||||||
|
if r.status_code in (301, 302, 303, 307, 308):
|
||||||
|
location = r.headers.get("location")
|
||||||
|
if not location:
|
||||||
|
return r
|
||||||
|
current = urljoin(str(r.url), location)
|
||||||
|
continue
|
||||||
|
return r
|
||||||
|
raise SkillImportError("too many redirects while fetching skill bundle")
|
||||||
|
|
||||||
|
|
||||||
def parse_skill_source(url: str) -> ResolvedSource:
|
def parse_skill_source(url: str) -> ResolvedSource:
|
||||||
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
|
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
|
||||||
raw = (url or "").strip()
|
raw = (url or "").strip()
|
||||||
@@ -76,11 +122,7 @@ def parse_skill_source(url: str) -> ResolvedSource:
|
|||||||
|
|
||||||
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
|
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
|
||||||
if "skills.sh" in raw and "github.com" not in raw:
|
if "skills.sh" in raw and "github.com" not in raw:
|
||||||
ok, reason = check_outbound_url(raw)
|
r = _get_checked(raw, timeout=20.0)
|
||||||
if not ok:
|
|
||||||
raise SkillImportError(reason)
|
|
||||||
with httpx.Client(follow_redirects=True, timeout=20.0) as client:
|
|
||||||
r = client.get(raw)
|
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
raise _github_response_error(r)
|
raise _github_response_error(r)
|
||||||
final = str(r.url)
|
final = str(r.url)
|
||||||
@@ -165,11 +207,7 @@ def _github_response_error(response: httpx.Response) -> SkillImportError:
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_bytes(url: str) -> bytes:
|
def _fetch_bytes(url: str) -> bytes:
|
||||||
ok, reason = check_outbound_url(url)
|
r = _get_checked(url, headers={"Accept": "application/vnd.github+json"}, timeout=30.0)
|
||||||
if not ok:
|
|
||||||
raise SkillImportError(reason)
|
|
||||||
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
|
|
||||||
r = client.get(url, headers={"Accept": "application/vnd.github+json"})
|
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
raise _github_response_error(r)
|
raise _github_response_error(r)
|
||||||
_assert_github_url(str(r.url), context="redirect target")
|
_assert_github_url(str(r.url), context="redirect target")
|
||||||
@@ -190,11 +228,7 @@ def _list_github_dir(src: ResolvedSource, rel_dir: str, out: Dict[str, str], *,
|
|||||||
if depth > 4 or len(out) >= MAX_FILES:
|
if depth > 4 or len(out) >= MAX_FILES:
|
||||||
return
|
return
|
||||||
url = _api_contents_url(src, rel_dir)
|
url = _api_contents_url(src, rel_dir)
|
||||||
ok, reason = check_outbound_url(url)
|
r = _get_checked(url, headers={"Accept": "application/vnd.github+json"}, timeout=30.0)
|
||||||
if not ok:
|
|
||||||
raise SkillImportError(reason)
|
|
||||||
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
|
|
||||||
r = client.get(url, headers={"Accept": "application/vnd.github+json"})
|
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
raise _github_response_error(r)
|
raise _github_response_error(r)
|
||||||
_assert_github_url(str(r.url), context="redirect target")
|
_assert_github_url(str(r.url), context="redirect target")
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class TTSService:
|
|||||||
if provider == "local":
|
if provider == "local":
|
||||||
kokoro = self._get_kokoro()
|
kokoro = self._get_kokoro()
|
||||||
return kokoro is not None and kokoro.available
|
return kokoro is not None and kokoro.available
|
||||||
if provider.startswith("endpoint:"):
|
if isinstance(provider, str) and provider.startswith("endpoint:"):
|
||||||
return True # assume reachable; errors surface at synthesis time
|
return True # assume reachable; errors surface at synthesis time
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,15 @@ def check_arch():
|
|||||||
def main():
|
def main():
|
||||||
print("\n=== Odysseus Setup ===\n")
|
print("\n=== Odysseus Setup ===\n")
|
||||||
|
|
||||||
|
# Load .env so pre-seeded ODYSSEUS_ADMIN_USER / ODYSSEUS_ADMIN_PASSWORD (and
|
||||||
|
# other deployment vars) are honored on native installs, not just when they
|
||||||
|
# are exported in the shell. Mirrors app.py: encoding="utf-8-sig" tolerates a
|
||||||
|
# UTF-8 BOM in a Notepad-saved .env. load_dotenv does not override already
|
||||||
|
# exported OS env vars, so the existing precedence is preserved. python-dotenv
|
||||||
|
# is a hard dependency (requirements.txt) and is verified by check_deps below.
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(BASE_DIR, ".env"), encoding="utf-8-sig")
|
||||||
|
|
||||||
# Fail fast with a clear message if the CPU architecture is wrong (Apple
|
# Fail fast with a clear message if the CPU architecture is wrong (Apple
|
||||||
# Silicon under an x86/Rosetta Python) before importing anything native.
|
# Silicon under an x86/Rosetta Python) before importing anything native.
|
||||||
check_arch()
|
check_arch()
|
||||||
|
|||||||
+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+.+"),
|
||||||
|
|
||||||
|
|||||||
+1445
-106
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -174,8 +174,20 @@ async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
|
|||||||
next_seq += 1
|
next_seq += 1
|
||||||
if run.status != "running":
|
if run.status != "running":
|
||||||
return
|
return
|
||||||
|
heartbeat_idx = 0
|
||||||
while True:
|
while True:
|
||||||
seq, ev = await q.get()
|
try:
|
||||||
|
seq, ev = await asyncio.wait_for(q.get(), timeout=10.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Keep slow local models/proxies alive while they prefill before
|
||||||
|
# the first token. SSE comments are ignored by the UI but reset
|
||||||
|
# browser/proxy idle timers, which prevents "empty response"
|
||||||
|
# disconnects on llama.cpp first-token latencies of 30s+.
|
||||||
|
if run.status == "running":
|
||||||
|
heartbeat_idx += 1
|
||||||
|
yield f": heartbeat {heartbeat_idx}\n\n"
|
||||||
|
continue
|
||||||
|
seq, ev = (None, None)
|
||||||
if seq is None: # end sentinel
|
if seq is None: # end sentinel
|
||||||
while next_seq < len(run.buffer): # flush any tail the sentinel raced
|
while next_seq < len(run.buffer): # flush any tail the sentinel raced
|
||||||
yield run.buffer[next_seq]
|
yield run.buffer[next_seq]
|
||||||
|
|||||||
@@ -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__)
|
||||||
@@ -22,8 +23,15 @@ from .subprocess_tools import BashTool, PythonTool
|
|||||||
from .web_tools import WebSearchTool, WebFetchTool
|
from .web_tools import WebSearchTool, WebFetchTool
|
||||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
||||||
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
||||||
|
from .interaction_tools import AskUserTool, UpdatePlanTool
|
||||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||||
from .bg_job_tools import ManageBgJobsTool
|
from .bg_job_tools import ManageBgJobsTool
|
||||||
|
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
||||||
|
from .admin_tools import (
|
||||||
|
ADMIN_TOOL_HANDLERS,
|
||||||
|
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||||
|
do_manage_tokens, do_manage_settings,
|
||||||
|
)
|
||||||
|
|
||||||
TOOL_HANDLERS = {
|
TOOL_HANDLERS = {
|
||||||
"bash": BashTool().execute,
|
"bash": BashTool().execute,
|
||||||
@@ -42,11 +50,19 @@ TOOL_HANDLERS = {
|
|||||||
"suggest_document": SuggestDocumentTool().execute,
|
"suggest_document": SuggestDocumentTool().execute,
|
||||||
"manage_documents": ManageDocumentTool().execute,
|
"manage_documents": ManageDocumentTool().execute,
|
||||||
"get_workspace": GetWorkspaceTool().execute,
|
"get_workspace": GetWorkspaceTool().execute,
|
||||||
|
"ask_user": AskUserTool().execute,
|
||||||
|
"update_plan": UpdatePlanTool().execute,
|
||||||
"chat_with_model": ChatWithModelTool().execute,
|
"chat_with_model": ChatWithModelTool().execute,
|
||||||
"ask_teacher": AskTeacherTool().execute,
|
"ask_teacher": AskTeacherTool().execute,
|
||||||
"list_models": ListModelsTool().execute,
|
"list_models": ListModelsTool().execute,
|
||||||
"manage_bg_jobs": ManageBgJobsTool().execute,
|
"manage_bg_jobs": ManageBgJobsTool().execute,
|
||||||
|
"create_session": CreateSessionTool().execute,
|
||||||
|
"list_sessions": ListSessionsTool().execute,
|
||||||
|
"send_to_session": SendToSessionTool().execute,
|
||||||
|
"manage_session": ManageSessionTool().execute,
|
||||||
}
|
}
|
||||||
|
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
|
||||||
|
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constants (re-exported for backward compatibility — single source of truth
|
# Constants (re-exported for backward compatibility — single source of truth
|
||||||
@@ -71,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
|
||||||
@@ -90,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"])
|
||||||
|
|
||||||
@@ -133,10 +150,5 @@ from src.tool_implementations import ( # noqa: E402, F401
|
|||||||
do_search_chats,
|
do_search_chats,
|
||||||
do_manage_skills,
|
do_manage_skills,
|
||||||
do_manage_tasks,
|
do_manage_tasks,
|
||||||
do_manage_endpoints,
|
|
||||||
do_manage_mcp,
|
|
||||||
do_manage_webhooks,
|
|
||||||
do_manage_tokens,
|
|
||||||
do_manage_settings,
|
|
||||||
do_api_call,
|
do_api_call,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,792 @@
|
|||||||
|
"""Config/integration admin agent tools (TOOL_HANDLERS).
|
||||||
|
|
||||||
|
Moved verbatim from tool_implementations.py as part of the tool-registry
|
||||||
|
migration (#3629, the `admin_tools.py` bullet): manage_endpoints / manage_mcp /
|
||||||
|
manage_webhooks / manage_tokens / manage_settings, plus manage_mcp's
|
||||||
|
command-allowlist guard. Each impl keeps its `do_*(content, owner)` shape;
|
||||||
|
ADMIN_TOOL_HANDLERS wraps them into registry `execute(content, ctx)` adapters
|
||||||
|
via one factory.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
from src.tool_utils import get_mcp_manager, _parse_tool_args
|
||||||
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Manage model endpoints: list, add, delete, enable, disable."""
|
||||||
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
|
try:
|
||||||
|
args = _parse_tool_args(content)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
|
action = args.get("action", "list")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if action == "list":
|
||||||
|
eps = db.query(ModelEndpoint).all()
|
||||||
|
items = [{"id": e.id, "name": e.name, "base_url": e.base_url,
|
||||||
|
"is_enabled": e.is_enabled} for e in eps]
|
||||||
|
return {"response": f"{len(items)} endpoints", "endpoints": items, "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "add":
|
||||||
|
import uuid as _uuid
|
||||||
|
name = args.get("name", "")
|
||||||
|
base_url = args.get("base_url", "")
|
||||||
|
api_key = args.get("api_key", "")
|
||||||
|
if not base_url:
|
||||||
|
return {"error": "base_url is required", "exit_code": 1}
|
||||||
|
eid = str(_uuid.uuid4())[:8]
|
||||||
|
from datetime import datetime
|
||||||
|
ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url,
|
||||||
|
api_key=api_key, is_enabled=True,
|
||||||
|
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||||
|
db.add(ep)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Added endpoint '{name or base_url}' (id: {eid})", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
eid = args.get("endpoint_id", "")
|
||||||
|
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
|
||||||
|
if not ep:
|
||||||
|
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
|
||||||
|
name = ep.name
|
||||||
|
db.delete(ep)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Deleted endpoint '{name}'", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action in ("enable", "disable"):
|
||||||
|
eid = args.get("endpoint_id", "")
|
||||||
|
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
|
||||||
|
if not ep:
|
||||||
|
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
|
||||||
|
ep.is_enabled = (action == "enable")
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Endpoint '{ep.name}' {action}d", "exit_code": 0}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"manage_endpoints error: {e}")
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MCP server management tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Parallel to routes/cookbook_helpers._validate_serve_cmd but deliberately the
|
||||||
|
# opposite policy: that gate guards an admin-only serve command and allows
|
||||||
|
# interpreters (python3/etc) because model-serving needs them, whereas this is
|
||||||
|
# the model/prompt-injection-reachable manage_mcp path, so interpreters and
|
||||||
|
# runners are denied here.
|
||||||
|
#
|
||||||
|
# Commands that can execute arbitrary code regardless of their arguments. These
|
||||||
|
# are NEVER accepted on the manage_mcp agent path, even if an operator lists one
|
||||||
|
# in ODYSSEUS_MCP_ALLOWED_COMMANDS -- a stdio server that genuinely needs an
|
||||||
|
# interpreter or package runner must be registered via the trusted admin route.
|
||||||
|
_MCP_DENIED_COMMANDS = frozenset({
|
||||||
|
"sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "ash", "busybox",
|
||||||
|
"cmd", "command.com", "powershell", "pwsh",
|
||||||
|
"python", "pypy", "node", "nodejs", "deno", "bun", "ruby", "jruby",
|
||||||
|
"perl", "raku", "php", "lua", "luajit", "tclsh", "wish", "expect", "rscript",
|
||||||
|
"groovy", "scala", "elixir", "erl", "iex", "java", "javac", "jshell", "jbang",
|
||||||
|
"kotlin", "kotlinc", "dotnet", "mono", "swift", "osascript", "tsx", "ts-node",
|
||||||
|
"npx", "bunx", "uvx", "pipx", "npm", "pnpm", "yarn", "pip", "uv",
|
||||||
|
"gem", "cargo", "go", "bundle", "poetry", "conda", "mamba", "brew",
|
||||||
|
"apt", "apt-get", "yum", "dnf", "pacman", "apk",
|
||||||
|
"env", "xargs", "nohup", "setsid", "nice", "ionice", "time", "timeout",
|
||||||
|
"watch", "stdbuf", "unbuffer", "script", "ssh", "scp", "sshpass", "sudo",
|
||||||
|
"doas", "su", "make", "cmake", "docker", "podman", "kubectl", "find",
|
||||||
|
"awk", "gawk", "sed", "vi", "vim", "nvim", "emacs", "ed", "tee", "eval",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Argv flags that make even an allowlisted binary execute inline code. Matched
|
||||||
|
# by prefix so glued forms (-cimport os, --eval=...) are caught, not just the
|
||||||
|
# exact-token form.
|
||||||
|
_MCP_CODE_EXEC_SHORT_FLAGS = ("-c", "-e", "-m")
|
||||||
|
_MCP_CODE_EXEC_LONG_FLAGS = ("--eval", "--exec", "--print", "--module", "--command", "--require")
|
||||||
|
|
||||||
|
_MCP_URL_SCHEMES = ("http://", "https://", "ftp://", "ftps://", "file://", "data:", "jar:", "blob:")
|
||||||
|
|
||||||
|
# Shell metacharacters refused in command/args. Args are passed as an argv list
|
||||||
|
# (no shell), but refusing these keeps the surface narrow and obvious.
|
||||||
|
_MCP_SHELL_METACHARS = set(";|&$`><\n\r")
|
||||||
|
|
||||||
|
# Env vars that let a child process load attacker-supplied code before main().
|
||||||
|
_MCP_DANGEROUS_ENV = frozenset({
|
||||||
|
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "DYLD_INSERT_LIBRARIES",
|
||||||
|
"DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "PYTHONPATH", "PYTHONSTARTUP",
|
||||||
|
"PYTHONHOME", "PYTHONEXECUTABLE", "NODE_OPTIONS", "NODE_PATH", "BASH_ENV",
|
||||||
|
"ENV", "SHELLOPTS", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "GEM_PATH",
|
||||||
|
"R_PROFILE", "R_HOME", "PATH", "IFS", "PROMPT_COMMAND",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_allowed_commands() -> set:
|
||||||
|
"""Operator-configured allowlist of safe MCP launcher basenames for the agent
|
||||||
|
path. Empty by default; set ODYSSEUS_MCP_ALLOWED_COMMANDS (comma-separated)
|
||||||
|
to opt specific trusted binaries in. Denied commands are rejected even if
|
||||||
|
listed here."""
|
||||||
|
raw = os.environ.get("ODYSSEUS_MCP_ALLOWED_COMMANDS", "")
|
||||||
|
return {c.strip().lower() for c in raw.split(",") if c.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_mcp_command(command, args, env) -> Optional[str]:
|
||||||
|
"""Validate a model-supplied stdio MCP registration. Returns an error string
|
||||||
|
if it must be rejected, else None.
|
||||||
|
|
||||||
|
Closes the RCE where manage_mcp 'add' passed prompt-injection-controlled
|
||||||
|
command/args/env straight to a subprocess spawn (issue #438): a payload
|
||||||
|
smuggled into a skill description, memory entry, fetched page, or email body
|
||||||
|
could register a stdio server running arbitrary code as the app UID.
|
||||||
|
"""
|
||||||
|
if not isinstance(command, str) or not command.strip():
|
||||||
|
return "command must be a non-empty string"
|
||||||
|
command = command.strip()
|
||||||
|
if "/" in command or "\\" in command:
|
||||||
|
return "command must be a bare executable name, not a path"
|
||||||
|
if any(ch in _MCP_SHELL_METACHARS for ch in command):
|
||||||
|
return "command contains shell metacharacters"
|
||||||
|
base = command.lower()
|
||||||
|
if base.endswith(".exe") or base.endswith(".cmd") or base.endswith(".bat"):
|
||||||
|
base = base.rsplit(".", 1)[0]
|
||||||
|
# Canonicalize a trailing version suffix so versioned aliases collapse to the
|
||||||
|
# family name (python3.11 -> python, node18 -> node, pip3 -> pip); both the
|
||||||
|
# raw basename and the canonical form are denied, so an operator cannot
|
||||||
|
# accidentally allowlist a runtime alias back into the path.
|
||||||
|
canon = re.sub(r"[-_.]?\d+(?:\.\d+)*$", "", base)
|
||||||
|
if base in _MCP_DENIED_COMMANDS or canon in _MCP_DENIED_COMMANDS:
|
||||||
|
return (
|
||||||
|
f"command '{command}' is not allowed on the agent MCP path: "
|
||||||
|
"interpreters, runtimes, package runners, and shells can execute "
|
||||||
|
"arbitrary code. Register such a server via the admin route instead."
|
||||||
|
)
|
||||||
|
if base not in _mcp_allowed_commands():
|
||||||
|
return (
|
||||||
|
f"command '{command}' is not in the MCP allowlist. Add it to "
|
||||||
|
"ODYSSEUS_MCP_ALLOWED_COMMANDS if you trust it, or register the "
|
||||||
|
"server via the admin route."
|
||||||
|
)
|
||||||
|
|
||||||
|
if args is not None:
|
||||||
|
if isinstance(args, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(args)
|
||||||
|
except Exception:
|
||||||
|
return "args must be a JSON list"
|
||||||
|
if not isinstance(args, list):
|
||||||
|
return "args must be a list"
|
||||||
|
for a in args:
|
||||||
|
if not isinstance(a, str):
|
||||||
|
return "args must all be strings"
|
||||||
|
s = a.strip()
|
||||||
|
low = s.lower()
|
||||||
|
if any(s == f or s.startswith(f) for f in _MCP_CODE_EXEC_SHORT_FLAGS):
|
||||||
|
return f"arg '{a}' is a code-execution flag and is not allowed"
|
||||||
|
if any(low == f or low.startswith(f + "=") for f in _MCP_CODE_EXEC_LONG_FLAGS):
|
||||||
|
return f"arg '{a}' is a code-execution flag and is not allowed"
|
||||||
|
if any(low.startswith(u) for u in _MCP_URL_SCHEMES):
|
||||||
|
return f"arg '{a}' is a remote URL and is not allowed"
|
||||||
|
if any(ch in _MCP_SHELL_METACHARS for ch in a):
|
||||||
|
return f"arg '{a}' contains shell metacharacters"
|
||||||
|
|
||||||
|
if env:
|
||||||
|
if isinstance(env, str):
|
||||||
|
try:
|
||||||
|
env = json.loads(env)
|
||||||
|
except Exception:
|
||||||
|
return "env must be a JSON object"
|
||||||
|
if not isinstance(env, dict):
|
||||||
|
return "env must be an object"
|
||||||
|
for k in env:
|
||||||
|
if str(k).strip().upper() in _MCP_DANGEROUS_ENV:
|
||||||
|
return f"env var '{k}' can inject code into the child process and is not allowed"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def do_manage_mcp(content: str, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Manage MCP servers: list, add, delete, enable, disable, reconnect."""
|
||||||
|
try:
|
||||||
|
args = _parse_tool_args(content)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
|
action = args.get("action", "list")
|
||||||
|
|
||||||
|
if action == "list":
|
||||||
|
mcp = get_mcp_manager()
|
||||||
|
if not mcp:
|
||||||
|
return {"response": "No MCP manager available", "servers": [], "exit_code": 0}
|
||||||
|
from core.database import SessionLocal, McpServer
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
servers = db.query(McpServer).all()
|
||||||
|
items = []
|
||||||
|
for s in servers:
|
||||||
|
st = mcp.get_server_status(s.id)
|
||||||
|
status = st.get("status", "disconnected")
|
||||||
|
tool_count = st.get("tool_count", 0)
|
||||||
|
items.append({"id": s.id, "name": s.name, "transport": s.transport,
|
||||||
|
"is_enabled": s.is_enabled, "status": status,
|
||||||
|
"tool_count": tool_count})
|
||||||
|
return {"response": f"{len(items)} MCP servers", "servers": items, "exit_code": 0}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
elif action == "add":
|
||||||
|
from core.database import SessionLocal, McpServer
|
||||||
|
import uuid as _uuid
|
||||||
|
from datetime import datetime
|
||||||
|
name = args.get("name", "")
|
||||||
|
command = args.get("command", "")
|
||||||
|
cmd_args = args.get("args", [])
|
||||||
|
env = args.get("env", {})
|
||||||
|
if not name or not command:
|
||||||
|
return {"error": "name and command are required", "exit_code": 1}
|
||||||
|
# Validate BEFORE any DB write or spawn: a rejected registration must
|
||||||
|
# leave no enabled row (which would otherwise auto-reconnect on restart)
|
||||||
|
# and must not attempt a connection.
|
||||||
|
_mcp_err = _validate_mcp_command(command, cmd_args, env)
|
||||||
|
if _mcp_err:
|
||||||
|
return {"error": f"manage_mcp: refused unsafe server registration: {_mcp_err}", "exit_code": 1}
|
||||||
|
sid = str(_uuid.uuid4())[:8]
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = McpServer(id=sid, name=name, transport="stdio", command=command,
|
||||||
|
args=json.dumps(cmd_args) if isinstance(cmd_args, list) else cmd_args,
|
||||||
|
env=json.dumps(env) if isinstance(env, dict) else env,
|
||||||
|
is_enabled=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||||
|
db.add(srv)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
# Try to connect
|
||||||
|
mcp = get_mcp_manager()
|
||||||
|
tool_count = 0
|
||||||
|
if mcp:
|
||||||
|
try:
|
||||||
|
await mcp.connect_server(
|
||||||
|
sid, name, "stdio", command=command,
|
||||||
|
args=cmd_args if isinstance(cmd_args, list) else json.loads(cmd_args),
|
||||||
|
env=env if isinstance(env, dict) else json.loads(env),
|
||||||
|
)
|
||||||
|
st = mcp.get_server_status(sid)
|
||||||
|
tool_count = st.get("tool_count", 0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"MCP connect failed for {name}: {e}")
|
||||||
|
return {"response": f"Added MCP server '{name}' ({tool_count} tools)", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
sid = args.get("server_id", "")
|
||||||
|
from core.database import SessionLocal, McpServer
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db.query(McpServer).filter(McpServer.id == sid).first()
|
||||||
|
if not srv:
|
||||||
|
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||||
|
name = srv.name
|
||||||
|
mcp = get_mcp_manager()
|
||||||
|
if mcp:
|
||||||
|
try:
|
||||||
|
await mcp.disconnect_server(sid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
db.delete(srv)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Deleted MCP server '{name}'", "exit_code": 0}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
elif action == "reconnect":
|
||||||
|
sid = args.get("server_id", "")
|
||||||
|
mcp = get_mcp_manager()
|
||||||
|
if not mcp:
|
||||||
|
return {"error": "MCP manager not available", "exit_code": 1}
|
||||||
|
try:
|
||||||
|
await mcp.disconnect_server(sid)
|
||||||
|
from core.database import SessionLocal, McpServer
|
||||||
|
db2 = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db2.query(McpServer).filter(McpServer.id == sid).first()
|
||||||
|
if srv:
|
||||||
|
_args = json.loads(srv.args) if srv.args else []
|
||||||
|
_env = json.loads(srv.env) if srv.env else {}
|
||||||
|
await mcp.connect_server(
|
||||||
|
server_id=sid,
|
||||||
|
name=srv.name,
|
||||||
|
transport=srv.transport,
|
||||||
|
command=srv.command,
|
||||||
|
args=_args,
|
||||||
|
env=_env,
|
||||||
|
url=srv.url,
|
||||||
|
)
|
||||||
|
st = mcp.get_server_status(sid)
|
||||||
|
return {"response": f"Reconnected '{srv.name}' ({st.get('tool_count', 0)} tools)", "exit_code": 0}
|
||||||
|
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||||
|
finally:
|
||||||
|
db2.close()
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
|
||||||
|
elif action in ("enable", "disable"):
|
||||||
|
sid = args.get("server_id", "")
|
||||||
|
from core.database import SessionLocal, McpServer
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db.query(McpServer).filter(McpServer.id == sid).first()
|
||||||
|
if not srv:
|
||||||
|
return {"error": f"Server {sid} not found", "exit_code": 1}
|
||||||
|
srv.is_enabled = (action == "enable")
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"MCP server '{srv.name}' {action}d", "exit_code": 0}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
elif action == "list_tools":
|
||||||
|
mcp = get_mcp_manager()
|
||||||
|
if not mcp:
|
||||||
|
return {"response": "No MCP manager", "tools": [], "exit_code": 0}
|
||||||
|
tools = mcp.get_all_tools()
|
||||||
|
items = [{"name": t["name"], "server": t["server_name"],
|
||||||
|
"description": t.get("description", "")[:100]} for t in tools]
|
||||||
|
return {"response": f"{len(items)} MCP tools available", "tools": items, "exit_code": 0}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Webhook management tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def do_manage_webhooks(content: str, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Manage webhooks: list, add, delete, enable, disable, test."""
|
||||||
|
from core.database import SessionLocal
|
||||||
|
try:
|
||||||
|
args = _parse_tool_args(content)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
|
action = args.get("action", "list")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from core.database import Webhook
|
||||||
|
if action == "list":
|
||||||
|
hooks = db.query(Webhook).all()
|
||||||
|
items = [{"id": h.id, "name": h.name, "url": h.url,
|
||||||
|
"events": h.events, "is_active": h.is_active} for h in hooks]
|
||||||
|
return {"response": f"{len(items)} webhooks", "webhooks": items, "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "add":
|
||||||
|
import uuid as _uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from src.webhook_manager import validate_events, validate_webhook_url
|
||||||
|
name = args.get("name", "")
|
||||||
|
url = args.get("url", "")
|
||||||
|
events = args.get("events", "chat.completed")
|
||||||
|
if not url:
|
||||||
|
return {"error": "url is required", "exit_code": 1}
|
||||||
|
try:
|
||||||
|
url = validate_webhook_url(url)
|
||||||
|
events = validate_events(events)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
wid = str(_uuid.uuid4())[:8]
|
||||||
|
hook = Webhook(id=wid, name=name or url, url=url,
|
||||||
|
events=events, is_active=True,
|
||||||
|
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||||
|
db.add(hook)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Added webhook '{name or url}'", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
wid = args.get("webhook_id", "")
|
||||||
|
hook = db.query(Webhook).filter(Webhook.id == wid).first()
|
||||||
|
if not hook:
|
||||||
|
return {"error": f"Webhook {wid} not found", "exit_code": 1}
|
||||||
|
name = hook.name
|
||||||
|
db.delete(hook)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Deleted webhook '{name}'", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action in ("enable", "disable"):
|
||||||
|
wid = args.get("webhook_id", "")
|
||||||
|
hook = db.query(Webhook).filter(Webhook.id == wid).first()
|
||||||
|
if not hook:
|
||||||
|
return {"error": f"Webhook {wid} not found", "exit_code": 1}
|
||||||
|
hook.is_active = (action == "enable")
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Webhook '{hook.name}' {action}d", "exit_code": 0}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"manage_webhooks error: {e}")
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API token management tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def do_manage_tokens(content: str, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Manage API tokens: list, create, delete."""
|
||||||
|
from core.database import SessionLocal, ApiToken
|
||||||
|
try:
|
||||||
|
args = _parse_tool_args(content)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
|
action = args.get("action", "list")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if action == "list":
|
||||||
|
tokens = db.query(ApiToken).all()
|
||||||
|
items = [{"id": t.id, "name": t.name, "token_prefix": t.token_prefix + "...",
|
||||||
|
"is_active": t.is_active} for t in tokens]
|
||||||
|
return {"response": f"{len(items)} API tokens", "tokens": items, "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "create":
|
||||||
|
import uuid as _uuid, secrets, bcrypt
|
||||||
|
from datetime import datetime
|
||||||
|
name = args.get("name", "API Token")
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
||||||
|
tid = str(_uuid.uuid4())[:8]
|
||||||
|
t = ApiToken(id=tid, name=name, token_hash=token_hash,
|
||||||
|
token_prefix=raw_token[:8], is_active=True,
|
||||||
|
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
|
||||||
|
db.add(t)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Created token '{name}'", "token": raw_token, "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
tid = args.get("token_id", "")
|
||||||
|
t = db.query(ApiToken).filter(ApiToken.id == tid).first()
|
||||||
|
if not t:
|
||||||
|
return {"error": f"Token {tid} not found", "exit_code": 1}
|
||||||
|
name = t.name
|
||||||
|
db.delete(t)
|
||||||
|
db.commit()
|
||||||
|
return {"response": f"Deleted token '{name}'", "exit_code": 0}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"manage_tokens error: {e}")
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings/preferences management tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Manage user settings and preferences."""
|
||||||
|
try:
|
||||||
|
args = _parse_tool_args(content)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
|
action = args.get("action", "list")
|
||||||
|
|
||||||
|
from core.database import SessionLocal
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# set/get/list/delete operate on the REAL app settings (the same store
|
||||||
|
# the Settings panel writes), so changing a model / voice / search
|
||||||
|
# engine / reminder channel from chat actually takes effect.
|
||||||
|
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
|
||||||
|
|
||||||
|
# Secrets/credentials the agent must NOT write: kept read-only (masked)
|
||||||
|
# so API keys never flow through chat. User sets these in the panel.
|
||||||
|
_SECRET_KEYS = {
|
||||||
|
"brave_api_key", "google_pse_key", "google_pse_cx",
|
||||||
|
"tavily_api_key", "serper_api_key", "app_public_url",
|
||||||
|
}
|
||||||
|
def _is_secret(k):
|
||||||
|
# `token` must be a suffix, not a substring: otherwise the int
|
||||||
|
# setting `agent_input_token_budget` (which even has a "token budget"
|
||||||
|
# alias to set it from chat) is wrongly classified as a credential.
|
||||||
|
return (
|
||||||
|
k in _SECRET_KEYS
|
||||||
|
or k.endswith("token")
|
||||||
|
or any(t in k for t in ("api_key", "_key", "secret", "password"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Friendly aliases → real keys, so natural phrasing resolves.
|
||||||
|
_ALIASES_SET = {
|
||||||
|
"voice": "tts_voice", "tts voice": "tts_voice", "tts": "tts_enabled",
|
||||||
|
"text to speech": "tts_enabled", "tts provider": "tts_provider",
|
||||||
|
"speech speed": "tts_speed", "voice speed": "tts_speed",
|
||||||
|
"stt": "stt_enabled", "speech to text": "stt_enabled", "transcription": "stt_enabled",
|
||||||
|
"search engine": "search_provider", "search provider": "search_provider",
|
||||||
|
"search results": "search_result_count", "result count": "search_result_count",
|
||||||
|
"default model": "default_model", "chat model": "default_model",
|
||||||
|
"default endpoint": "default_endpoint_id",
|
||||||
|
"task model": "task_model", "background model": "task_model",
|
||||||
|
"teacher model": "teacher_model", "teacher": "teacher_enabled",
|
||||||
|
"utility model": "utility_model", "research model": "research_model",
|
||||||
|
"research max tokens": "research_max_tokens",
|
||||||
|
"vision model": "vision_model", "vision": "vision_enabled",
|
||||||
|
"image model": "image_model", "image quality": "image_quality",
|
||||||
|
"image gen": "image_gen_enabled", "image generation": "image_gen_enabled",
|
||||||
|
"reminder channel": "reminder_channel", "reminders": "reminder_channel",
|
||||||
|
"ntfy topic": "reminder_ntfy_topic",
|
||||||
|
"webhook integration": "reminder_webhook_integration_id",
|
||||||
|
"webhook template": "reminder_webhook_payload_template", "webhook payload": "reminder_webhook_payload_template",
|
||||||
|
"agent tool calls": "agent_max_tool_calls", "max tool calls": "agent_max_tool_calls",
|
||||||
|
"agent timeout": "agent_stream_timeout_seconds", "stream timeout": "agent_stream_timeout_seconds",
|
||||||
|
"token budget": "agent_input_token_budget", "input budget": "agent_input_token_budget",
|
||||||
|
"hard max": "agent_input_token_hard_max",
|
||||||
|
"token budget cap": "agent_input_token_hard_max",
|
||||||
|
"input budget cap": "agent_input_token_hard_max",
|
||||||
|
}
|
||||||
|
def _resolve(k):
|
||||||
|
k2 = (k or "").strip().lower()
|
||||||
|
if k2 in DEFAULT_SETTINGS:
|
||||||
|
return k2
|
||||||
|
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||||
|
|
||||||
|
_ENUMS = {
|
||||||
|
"image_quality": ["low", "medium", "high"],
|
||||||
|
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
|
||||||
|
}
|
||||||
|
def _coerce(value, default):
|
||||||
|
if isinstance(default, bool):
|
||||||
|
return value if isinstance(value, bool) else str(value).strip().lower() in ("true", "on", "yes", "1", "enable", "enabled")
|
||||||
|
if isinstance(default, int):
|
||||||
|
return int(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _model_slug(value: str) -> str:
|
||||||
|
import re as _re
|
||||||
|
return _re.sub(r"[^a-z0-9]+", "", (value or "").lower())
|
||||||
|
|
||||||
|
def _endpoint_model_from_cache(model_query: str):
|
||||||
|
"""Resolve friendly model text to an enabled endpoint + real model id.
|
||||||
|
|
||||||
|
The Settings UI stores both `<prefix>_endpoint_id` and
|
||||||
|
`<prefix>_model`; writing only the model leaves the runtime on the
|
||||||
|
old endpoint. Prefer cached model lists so this stays fast/offline.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
import re as _re
|
||||||
|
from core.database import ModelEndpoint
|
||||||
|
|
||||||
|
wanted = (model_query or "").strip()
|
||||||
|
wanted_slug = _model_slug(wanted)
|
||||||
|
wanted_tokens = [_model_slug(t) for t in _re.findall(r"[A-Za-z0-9]+", wanted)]
|
||||||
|
wanted_tokens = [t for t in wanted_tokens if t]
|
||||||
|
if not wanted_slug:
|
||||||
|
return None
|
||||||
|
best = None
|
||||||
|
for ep in db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all():
|
||||||
|
raw_models = []
|
||||||
|
try:
|
||||||
|
raw_models = _json.loads(ep.cached_models or "[]") or []
|
||||||
|
except Exception:
|
||||||
|
raw_models = []
|
||||||
|
# If cache is empty, still allow matching against endpoint name
|
||||||
|
# for callers using model@endpoint elsewhere later.
|
||||||
|
for mid in raw_models:
|
||||||
|
mid = str(mid)
|
||||||
|
mid_slug = _model_slug(mid)
|
||||||
|
if not mid_slug:
|
||||||
|
continue
|
||||||
|
exact = mid.lower() == wanted.lower()
|
||||||
|
compact_match = wanted_slug in mid_slug or mid_slug in wanted_slug
|
||||||
|
token_match = bool(wanted_tokens) and all(tok in mid_slug for tok in wanted_tokens)
|
||||||
|
if exact or compact_match or token_match:
|
||||||
|
score = 3 if exact else (2 if compact_match else 1)
|
||||||
|
if not best or score > best[0]:
|
||||||
|
best = (score, ep.id, mid)
|
||||||
|
if best:
|
||||||
|
return {"endpoint_id": best[1], "model": best[2]}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _mask(k, v):
|
||||||
|
return "••••• (set in panel)" if _is_secret(k) and v else v
|
||||||
|
|
||||||
|
if action == "list":
|
||||||
|
s = load_settings()
|
||||||
|
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
|
||||||
|
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "get":
|
||||||
|
key = _resolve(args.get("key", ""))
|
||||||
|
if not key:
|
||||||
|
return {"error": "key is required", "exit_code": 1}
|
||||||
|
if key not in DEFAULT_SETTINGS:
|
||||||
|
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
|
||||||
|
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
|
||||||
|
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "set":
|
||||||
|
raw = args.get("key", "")
|
||||||
|
value = args.get("value")
|
||||||
|
if not raw:
|
||||||
|
return {"error": "key is required", "exit_code": 1}
|
||||||
|
key = _resolve(raw)
|
||||||
|
if key not in DEFAULT_SETTINGS:
|
||||||
|
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
|
||||||
|
if _is_secret(key):
|
||||||
|
return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
|
||||||
|
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
|
||||||
|
# have no safe scalar coercion; _coerce would pass a bare string
|
||||||
|
# straight through and clobber the structure. Refuse them here; they're
|
||||||
|
# edited in their dedicated panels. (reset/delete still restore the
|
||||||
|
# default structure, which is safe.)
|
||||||
|
if isinstance(DEFAULT_SETTINGS[key], (dict, list)):
|
||||||
|
return {"response": f"'{key}' is a structured setting. Edit it in its panel, not from chat. (You can reset it to default here.)", "exit_code": 0}
|
||||||
|
try:
|
||||||
|
value = _coerce(value, DEFAULT_SETTINGS[key])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {"error": f"'{value}' isn't a valid value for {key} (expected {type(DEFAULT_SETTINGS[key]).__name__}).", "exit_code": 1}
|
||||||
|
if key in _ENUMS and str(value).lower() not in _ENUMS[key]:
|
||||||
|
return {"error": f"{key} must be one of: {', '.join(_ENUMS[key])}.", "exit_code": 1}
|
||||||
|
s = load_settings()
|
||||||
|
s[key] = value
|
||||||
|
if key in {"default_model", "research_model", "utility_model", "task_model", "vision_model", "image_model"}:
|
||||||
|
resolved = _endpoint_model_from_cache(str(value))
|
||||||
|
if resolved:
|
||||||
|
prefix = key[:-6]
|
||||||
|
s[f"{prefix}_endpoint_id"] = resolved["endpoint_id"]
|
||||||
|
s[key] = resolved["model"]
|
||||||
|
value = resolved["model"]
|
||||||
|
save_settings(s)
|
||||||
|
if key.endswith("_model") and s.get(f"{key[:-6]}_endpoint_id"):
|
||||||
|
return {"response": f"Set {key} = {value} (endpoint {s.get(f'{key[:-6]}_endpoint_id')}).", "exit_code": 0}
|
||||||
|
return {"response": f"Set {key} = {value}.", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action == "delete" or action == "reset":
|
||||||
|
key = _resolve(args.get("key", ""))
|
||||||
|
if key not in DEFAULT_SETTINGS:
|
||||||
|
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
|
||||||
|
if _is_secret(key):
|
||||||
|
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
|
||||||
|
s = load_settings()
|
||||||
|
s[key] = DEFAULT_SETTINGS[key]
|
||||||
|
save_settings(s)
|
||||||
|
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
||||||
|
|
||||||
|
elif action in ("disable_tool", "enable_tool", "list_tools"):
|
||||||
|
# Tool-toggle actions. These edit settings.json:disabled_tools
|
||||||
|
# (the global list read on every chat request) rather than
|
||||||
|
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
||||||
|
# "search" -> "web_search", "browser" -> "builtin_browser",
|
||||||
|
# "documents" -> the document tool set, "memory" ->
|
||||||
|
# manage_memory, etc.
|
||||||
|
from src.settings import get_setting, save_settings, load_settings
|
||||||
|
_ALIASES = {
|
||||||
|
"shell": ["bash"],
|
||||||
|
"terminal": ["bash"],
|
||||||
|
"search": ["web_search", "web_fetch"],
|
||||||
|
"web": ["web_search", "web_fetch"],
|
||||||
|
"browser": ["builtin_browser"],
|
||||||
|
"documents": ["create_document", "edit_document", "update_document", "suggest_document"],
|
||||||
|
"doc": ["create_document", "edit_document", "update_document", "suggest_document"],
|
||||||
|
"memory": ["manage_memory"],
|
||||||
|
"skills": ["manage_skills"],
|
||||||
|
"images": ["generate_image"],
|
||||||
|
"image": ["generate_image"],
|
||||||
|
"tasks": ["manage_tasks"],
|
||||||
|
"notes": ["manage_notes"],
|
||||||
|
"calendar": ["manage_calendar"],
|
||||||
|
# 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "list_tools":
|
||||||
|
current = get_setting("disabled_tools", []) or []
|
||||||
|
return {
|
||||||
|
"response": (
|
||||||
|
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
||||||
|
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
||||||
|
"memory, skills, images, tasks, notes, calendar, email."
|
||||||
|
),
|
||||||
|
"disabled": list(current),
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
|
||||||
|
if not tool_name:
|
||||||
|
return {"error": "tool name required (e.g. 'shell', 'search', 'bash')", "exit_code": 1}
|
||||||
|
targets = _ALIASES.get(tool_name, [tool_name])
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
current = list(settings.get("disabled_tools") or [])
|
||||||
|
before = set(current)
|
||||||
|
if action == "disable_tool":
|
||||||
|
for t in targets:
|
||||||
|
if t not in current:
|
||||||
|
current.append(t)
|
||||||
|
else: # enable_tool
|
||||||
|
current = [t for t in current if t not in targets]
|
||||||
|
after = set(current)
|
||||||
|
settings["disabled_tools"] = current
|
||||||
|
save_settings(settings)
|
||||||
|
|
||||||
|
verb = "Disabled" if action == "disable_tool" else "Enabled"
|
||||||
|
changed = sorted(after.symmetric_difference(before))
|
||||||
|
return {
|
||||||
|
"response": (
|
||||||
|
f"{verb} {tool_name} ({', '.join(targets)}). "
|
||||||
|
f"Now disabled: {', '.join(current) if current else '(none)'}."
|
||||||
|
),
|
||||||
|
"changed": changed,
|
||||||
|
"disabled": list(current),
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown action: {action}", "exit_code": 1}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"manage_settings error: {e}")
|
||||||
|
return {"error": str(e), "exit_code": 1}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API call tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── registry adapters ────────────────────────────────────────────────────────
|
||||||
|
def _owner_adapter(fn):
|
||||||
|
"""Wrap a do_*(content, owner) impl as a registry execute(content, ctx)."""
|
||||||
|
async def _execute(content: str, ctx: dict) -> dict:
|
||||||
|
return await fn(content, ctx.get("owner"))
|
||||||
|
return _execute
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_TOOL_HANDLERS = {
|
||||||
|
"manage_endpoints": _owner_adapter(do_manage_endpoints),
|
||||||
|
"manage_mcp": _owner_adapter(do_manage_mcp),
|
||||||
|
"manage_webhooks": _owner_adapter(do_manage_webhooks),
|
||||||
|
"manage_tokens": _owner_adapter(do_manage_tokens),
|
||||||
|
"manage_settings": _owner_adapter(do_manage_settings),
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import json
|
|
||||||
from src.constants import MAX_READ_CHARS
|
from src.constants import MAX_READ_CHARS
|
||||||
|
from src.tool_utils import _parse_tool_args, get_upload_handler
|
||||||
|
from src.upload_handler import reserve_upload_references
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_document_upload(owner: Optional[str], content: Any) -> Optional[str]:
|
||||||
|
"""Reserve explicit upload URLs before an agent persists document text."""
|
||||||
|
return reserve_upload_references(get_upload_handler(), owner, content)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Active document state
|
# Active document state
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -130,15 +136,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,40 +213,11 @@ 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_tool_args(content):
|
|
||||||
"""Parse a tool-call argument blob.
|
|
||||||
|
|
||||||
Accepts either a JSON string or an already-decoded dict. Unwraps the
|
|
||||||
common `{"body": {...}}` envelope that smaller models emit when they
|
|
||||||
read tool descriptions like "Body is JSON: {...}" literally — they
|
|
||||||
pass `body` as a field name rather than treating it as a noun.
|
|
||||||
|
|
||||||
Returns a dict on success, raises ValueError on bad JSON.
|
|
||||||
"""
|
|
||||||
if isinstance(content, str):
|
|
||||||
try:
|
|
||||||
args = json.loads(content) if content.strip() else {}
|
|
||||||
except (json.JSONDecodeError, TypeError) as e:
|
|
||||||
raise ValueError(str(e))
|
|
||||||
elif isinstance(content, dict):
|
|
||||||
args = content
|
|
||||||
else:
|
|
||||||
args = {}
|
|
||||||
# Unwrap {"body": {...}} envelope — but only if `body` is the sole key
|
|
||||||
# and points at a dict. We don't want to clobber a legitimate `body`
|
|
||||||
# field on tools where it's a real arg (e.g. send_email body text).
|
|
||||||
if (
|
|
||||||
isinstance(args, dict)
|
|
||||||
and len(args) == 1
|
|
||||||
and "body" in args
|
|
||||||
and isinstance(args["body"], dict)
|
|
||||||
and "action" in args["body"] # extra safety: only unwrap if the inner dict looks like a tool call
|
|
||||||
):
|
|
||||||
args = args["body"]
|
|
||||||
return args
|
|
||||||
|
|
||||||
def parse_edit_blocks(content: str) -> list:
|
def parse_edit_blocks(content: str) -> list:
|
||||||
"""Parse <<<FIND>>>...<<<REPLACE>>>...<<<END>>> blocks."""
|
"""Parse <<<FIND>>>...<<<REPLACE>>>...<<<END>>> blocks."""
|
||||||
edits = []
|
edits = []
|
||||||
@@ -217,6 +249,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:
|
||||||
@@ -293,6 +390,13 @@ class CreateDocumentTool:
|
|||||||
return {"error": "Cannot create document in another user's session"}
|
return {"error": "Cannot create document in another user's session"}
|
||||||
_owner = _sess.owner if _sess else None
|
_owner = _sess.owner if _sess else None
|
||||||
|
|
||||||
|
missing_id = _missing_document_upload(_owner, content)
|
||||||
|
if missing_id:
|
||||||
|
return {
|
||||||
|
"error": f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
doc = Document(
|
doc = Document(
|
||||||
id=doc_id,
|
id=doc_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -364,6 +468,22 @@ class UpdateDocumentTool:
|
|||||||
if is_email_doc:
|
if is_email_doc:
|
||||||
doc.language = "email"
|
doc.language = "email"
|
||||||
|
|
||||||
|
missing_id = _missing_document_upload(owner, new_content)
|
||||||
|
if missing_id:
|
||||||
|
return {
|
||||||
|
"error": f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()),
|
||||||
@@ -421,6 +541,48 @@ 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"
|
||||||
|
missing_id = _missing_document_upload(owner, updated_content)
|
||||||
|
if missing_id:
|
||||||
|
return {
|
||||||
|
"error": f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -448,6 +610,22 @@ 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})"}
|
||||||
|
|
||||||
|
missing_id = _missing_document_upload(owner, updated_content)
|
||||||
|
if missing_id:
|
||||||
|
return {
|
||||||
|
"error": f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()),
|
||||||
@@ -596,9 +774,20 @@ class ManageDocumentTool:
|
|||||||
if not doc:
|
if not doc:
|
||||||
return {"error": f"Document '{doc_id}' not found", "exit_code": 1}
|
return {"error": f"Document '{doc_id}' not found", "exit_code": 1}
|
||||||
body = doc.current_content or ""
|
body = doc.current_content or ""
|
||||||
preview_limit = int(args.get("limit", MAX_READ_CHARS))
|
try:
|
||||||
truncated = len(body) > preview_limit
|
preview_limit = max(1, min(int(args.get("limit", MAX_READ_CHARS)), MAX_READ_CHARS))
|
||||||
preview = body[:preview_limit] + (f"\n... (truncated, {len(body)} chars total)" if truncated else "")
|
except (TypeError, ValueError):
|
||||||
|
preview_limit = MAX_READ_CHARS
|
||||||
|
try:
|
||||||
|
offset = max(0, int(args.get("offset", 0) or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
offset = 0
|
||||||
|
offset = min(offset, len(body))
|
||||||
|
end = min(offset + preview_limit, len(body))
|
||||||
|
truncated = end < len(body)
|
||||||
|
preview = body[offset:end]
|
||||||
|
if truncated:
|
||||||
|
preview += f"\n... (truncated, {len(body)} chars total; next_offset={end})"
|
||||||
anchor = f"[{doc.title}](#document-{doc.id})"
|
anchor = f"[{doc.title}](#document-{doc.id})"
|
||||||
return {
|
return {
|
||||||
"response": f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n{preview}\n```",
|
"response": f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n{preview}\n```",
|
||||||
@@ -609,6 +798,8 @@ class ManageDocumentTool:
|
|||||||
"size": len(body),
|
"size": len(body),
|
||||||
"content": preview,
|
"content": preview,
|
||||||
"truncated": truncated,
|
"truncated": truncated,
|
||||||
|
"offset": offset,
|
||||||
|
"next_offset": end if truncated else None,
|
||||||
},
|
},
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class AskUserTool:
|
||||||
|
async def execute(self, content, ctx):
|
||||||
|
"""
|
||||||
|
ask_user: the agent poses a multiple-choice question to the user to get a
|
||||||
|
decision/clarification. This is a pure UI-control marker — no subprocess,
|
||||||
|
no filesystem. It returns an `ask_user` payload that the agent loop turns
|
||||||
|
into an `ask_user` SSE event and then ENDS the turn, so the chat waits for
|
||||||
|
the user's selection (their choice arrives as the next message).
|
||||||
|
"""
|
||||||
|
question, options, multi = "", [], False
|
||||||
|
raw = (content or "").strip()
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw) if raw else {}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
parsed = {}
|
||||||
|
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
question = str(parsed.get("question", "")).strip()
|
||||||
|
multi = bool(parsed.get("multi") or parsed.get("multiSelect"))
|
||||||
|
for opt in (parsed.get("options") or []):
|
||||||
|
if isinstance(opt, dict):
|
||||||
|
label = str(opt.get("label", "")).strip()
|
||||||
|
descr = str(opt.get("description", "")).strip()
|
||||||
|
elif isinstance(opt, str):
|
||||||
|
label, descr = opt.strip(), ""
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if label:
|
||||||
|
options.append({"label": label, "description": descr})
|
||||||
|
else:
|
||||||
|
question = raw
|
||||||
|
|
||||||
|
if not question or len(options) < 2:
|
||||||
|
return "ask_user: invalid", {
|
||||||
|
"error": (
|
||||||
|
"ask_user needs a non-empty `question` and at least 2 `options` "
|
||||||
|
"(each an object with a `label`, optional `description`)."
|
||||||
|
),
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
options = options[:6] # keep the choice list sane
|
||||||
|
desc = f"ask_user: {question[:80]}"
|
||||||
|
labels = ", ".join(o["label"] for o in options)
|
||||||
|
result = {
|
||||||
|
"ask_user": {"question": question, "options": options, "multi": multi},
|
||||||
|
"output": f"Asked the user: {question}\nOptions: {labels}\nAwaiting their selection.",
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
logger.info("Tool executed: %s (%d options, multi=%s)", desc, len(options), multi)
|
||||||
|
return desc, result
|
||||||
|
|
||||||
|
class UpdatePlanTool:
|
||||||
|
async def execute(self, content, ctx):
|
||||||
|
"""
|
||||||
|
update_plan: the agent writes back to the active plan — tick an item done
|
||||||
|
or revise steps (e.g. when the user asks to change something). Pure UI
|
||||||
|
marker: returns a `plan_update` payload the agent loop turns into a
|
||||||
|
`plan_update` SSE event; the frontend replaces the stored plan and refreshes
|
||||||
|
the docked plan window. Does NOT end the turn.
|
||||||
|
"""
|
||||||
|
raw = (content or "").strip()
|
||||||
|
plan = ""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw) if raw else {}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
parsed = {}
|
||||||
|
|
||||||
|
if isinstance(parsed, dict) and parsed.get("plan"):
|
||||||
|
plan = str(parsed.get("plan", "")).strip()
|
||||||
|
else:
|
||||||
|
plan = raw
|
||||||
|
|
||||||
|
if not plan:
|
||||||
|
return "update_plan: invalid", {
|
||||||
|
"error": "update_plan needs a non-empty `plan` (the full updated checklist as markdown).",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
plan = plan[:8192]
|
||||||
|
done = plan.count("- [x]") + plan.count("- [X]")
|
||||||
|
total = done + plan.count("- [ ]")
|
||||||
|
desc = f"update_plan: {done}/{total} done" if total else "update_plan"
|
||||||
|
result = {
|
||||||
|
"plan_update": {"plan": plan},
|
||||||
|
"output": f"Plan updated ({done}/{total} steps complete)." if total else "Plan updated.",
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
logger.info("Tool executed: %s", desc)
|
||||||
|
return desc, result
|
||||||
@@ -10,6 +10,7 @@ Shared helpers that still live in ``src.ai_interaction`` and are used by tools
|
|||||||
not yet migrated (``_resolve_model``, ``AI_CHAT_TIMEOUT``) are imported lazily
|
not yet migrated (``_resolve_model``, ``AI_CHAT_TIMEOUT``) are imported lazily
|
||||||
inside the functions to avoid an import cycle at module load.
|
inside the functions to avoid an import cycle at module load.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ async def chat_with_model(content: str, session_id: Optional[str] = None, owner:
|
|||||||
return {"error": "No message provided (line 2+ is the message)"}
|
return {"error": "No message provided (line 2+ is the message)"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url, model, headers = _resolve_model(model_spec, owner=owner)
|
url, model, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Opt
|
|||||||
return {"error": "No teacher model configured. Specify a model name or set teacher_model in settings."}
|
return {"error": "No teacher model configured. Specify a model name or set teacher_model in settings."}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url, model, headers = _resolve_model(model_spec, owner=owner)
|
url, model, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
"""session_tools.py - agent tools for AI-to-AI session management.
|
||||||
|
|
||||||
|
Owns create_session, list_sessions, send_to_session and manage_session, moved
|
||||||
|
out of src.ai_interaction as part of the tool -> registry migration (#3629), and
|
||||||
|
their handler classes registered in TOOL_HANDLERS.
|
||||||
|
|
||||||
|
The session manager is a runtime-set singleton in src.ai_interaction, so each
|
||||||
|
function fetches it via get_session_manager() (imported here); _resolve_model and
|
||||||
|
AI_CHAT_TIMEOUT are reused from there too.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from src.ai_interaction import get_session_manager, _resolve_model, AI_CHAT_TIMEOUT
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Create a new chat session.
|
||||||
|
|
||||||
|
Content format:
|
||||||
|
Line 1: session name
|
||||||
|
Line 2: model_name (or model_name@endpoint_name)
|
||||||
|
"""
|
||||||
|
_session_manager = get_session_manager()
|
||||||
|
if not _session_manager:
|
||||||
|
return {"error": "Session manager not available"}
|
||||||
|
|
||||||
|
lines = content.strip().split("\n")
|
||||||
|
if len(lines) < 2:
|
||||||
|
return {"error": "Need 2 lines: session name, then model spec"}
|
||||||
|
|
||||||
|
name = lines[0].strip()
|
||||||
|
model_spec = lines[1].strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return {"error": "Session name cannot be empty"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
url, model, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
sid = str(uuid.uuid4())[:8]
|
||||||
|
try:
|
||||||
|
_session_manager.create_session(
|
||||||
|
session_id=sid,
|
||||||
|
name=name,
|
||||||
|
endpoint_url=url,
|
||||||
|
model=model,
|
||||||
|
rag=False,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
# Store headers on session for future calls
|
||||||
|
sess = _session_manager.get_session(sid)
|
||||||
|
if sess and headers:
|
||||||
|
sess.headers = headers
|
||||||
|
try:
|
||||||
|
from src.event_bus import fire_event
|
||||||
|
fire_event("session_created", owner)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||||
|
|
||||||
|
return {"session_id": sid, "name": name, "model": model, "endpoint_url": url}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"create_session failed: {e}")
|
||||||
|
return {"error": f"Failed to create session: {e}"}
|
||||||
|
|
||||||
|
async def list_sessions(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""List sessions sorted by most-recently-active first.
|
||||||
|
|
||||||
|
Output includes a relative "last active" timestamp per row so the
|
||||||
|
agent can answer "open my last chat" without guessing from titles.
|
||||||
|
The most-recent session is always first in the list.
|
||||||
|
|
||||||
|
Content = optional filter keyword (matches session name).
|
||||||
|
"""
|
||||||
|
_session_manager = get_session_manager()
|
||||||
|
if not _session_manager:
|
||||||
|
return {"error": "Session manager not available"}
|
||||||
|
|
||||||
|
keyword = content.strip().lower() if content.strip() else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from core.database import SessionLocal, Session as DbSession
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# Pull every session's last_accessed from the DB so we can sort
|
||||||
|
# by recency. In-memory sessions hold name + model + msg_count;
|
||||||
|
# the DB row holds the timestamps.
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db_rows = {r.id: r for r in db.query(DbSession).all()}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# SECURITY: scope to the caller's sessions. Passing None returned
|
||||||
|
# every user's sessions, which the agent tool then exposed via the
|
||||||
|
# "list my chats" reply.
|
||||||
|
sessions = _session_manager.get_sessions_for_user(owner)
|
||||||
|
rows = []
|
||||||
|
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():
|
||||||
|
continue
|
||||||
|
db_row = db_rows.get(sid)
|
||||||
|
# Prefer last_accessed; fall back to updated_at, then created_at.
|
||||||
|
ts = None
|
||||||
|
if db_row:
|
||||||
|
ts = getattr(db_row, 'last_accessed', None) or getattr(db_row, 'updated_at', None) or getattr(db_row, 'created_at', None)
|
||||||
|
rows.append((ts, sid, sess))
|
||||||
|
|
||||||
|
# Sort by timestamp DESC; rows without a timestamp sink to the bottom.
|
||||||
|
rows.sort(key=lambda r: r[0] or datetime.min, reverse=True)
|
||||||
|
|
||||||
|
def _rel(ts):
|
||||||
|
if not ts:
|
||||||
|
return 'never'
|
||||||
|
now = datetime.utcnow()
|
||||||
|
try:
|
||||||
|
if ts.tzinfo is not None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
diff = (now - ts).total_seconds()
|
||||||
|
except Exception:
|
||||||
|
return 'unknown'
|
||||||
|
if diff < 60: return 'just now'
|
||||||
|
if diff < 3600: return f'{int(diff / 60)}m ago'
|
||||||
|
if diff < 86400: return f'{int(diff / 3600)}h ago'
|
||||||
|
if diff < 86400 * 7: return f'{int(diff / 86400)}d ago'
|
||||||
|
return ts.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for i, (ts, sid, sess) in enumerate(rows):
|
||||||
|
if i >= 50:
|
||||||
|
lines.append(f"... and {len(rows) - 50} more (showing first 50)")
|
||||||
|
break
|
||||||
|
safe_name = (sess.name or "Untitled").replace("[", "\\[").replace("]", "\\]")
|
||||||
|
msg_count = getattr(sess, "message_count", 0) or 0
|
||||||
|
model = getattr(sess, "model", "unknown")
|
||||||
|
marker = " ← most recent" if i == 0 else ""
|
||||||
|
lines.append(f"- **[{safe_name}](#session-{sid})** (id: `{sid}`, model: {model}, {msg_count} msgs, last active {_rel(ts)}){marker}")
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return {"results": "No sessions found" + (f" matching '{keyword}'" if keyword else "") + "."}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"results": (
|
||||||
|
f"Found {len(rows)} session(s), sorted most-recent first:\n"
|
||||||
|
+ "\n".join(lines)
|
||||||
|
+ "\n\nAssistant: when replying to the user, preserve the chat-title markdown links exactly as shown, e.g. `[Chat](#session-id)`. Do not rewrite this as a plain, non-clickable table."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"list_sessions failed: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
async def send_to_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Send a message to an existing session and get a response.
|
||||||
|
|
||||||
|
Content format:
|
||||||
|
Line 1: session_id
|
||||||
|
Line 2+: message
|
||||||
|
"""
|
||||||
|
_session_manager = get_session_manager()
|
||||||
|
from src.llm_core import llm_call_async
|
||||||
|
from core.models import ChatMessage
|
||||||
|
|
||||||
|
if not _session_manager:
|
||||||
|
return {"error": "Session manager not available"}
|
||||||
|
|
||||||
|
lines = content.strip().split("\n", 1)
|
||||||
|
if len(lines) < 2:
|
||||||
|
return {"error": "Need 2 lines: session_id, then message"}
|
||||||
|
|
||||||
|
target_sid = lines[0].strip()
|
||||||
|
message = lines[1].strip()
|
||||||
|
|
||||||
|
sess = _session_manager.get_session(target_sid)
|
||||||
|
if not sess:
|
||||||
|
return {"error": f"Session '{target_sid}' not found"}
|
||||||
|
|
||||||
|
# Owner-scope: reject access to another user's session. When the caller is
|
||||||
|
# authenticated, a null-owner (legacy / auth-was-off) session is not theirs
|
||||||
|
# either — list_sessions (get_sessions_for_user) and manage_session already
|
||||||
|
# exclude those, so treating it as reachable here let an authenticated agent
|
||||||
|
# read/write a session the other tools hide. Require an exact owner match.
|
||||||
|
if owner and getattr(sess, "owner", None) != owner:
|
||||||
|
return {"error": f"Session '{target_sid}' not found"}
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
return {"error": "No message provided"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build context from session history
|
||||||
|
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})
|
||||||
|
|
||||||
|
response = await llm_call_async(
|
||||||
|
sess.endpoint_url, sess.model, context,
|
||||||
|
headers=sess.headers,
|
||||||
|
timeout=AI_CHAT_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save both messages to session
|
||||||
|
sess.add_message(ChatMessage("user", message))
|
||||||
|
sess.add_message(ChatMessage("assistant", response))
|
||||||
|
|
||||||
|
# Truncate for tool output
|
||||||
|
if len(response) > 10000:
|
||||||
|
response = response[:10000] + "\n... (truncated)"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": target_sid,
|
||||||
|
"session_name": sess.name,
|
||||||
|
"response": response,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"send_to_session failed: {e}")
|
||||||
|
return {"error": f"Failed to send to session: {e}"}
|
||||||
|
|
||||||
|
async def manage_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Manage sessions: rename, archive, delete, important, truncate, fork.
|
||||||
|
|
||||||
|
Content format:
|
||||||
|
Line 1: action (rename|archive|unarchive|delete|important|unimportant|truncate|fork)
|
||||||
|
Line 2: target session_id (or "current" to use the active session)
|
||||||
|
Line 3+: action-specific params (e.g. new name for rename, keep_count for truncate)
|
||||||
|
"""
|
||||||
|
_session_manager = get_session_manager()
|
||||||
|
if not _session_manager:
|
||||||
|
return {"error": "Session manager not available"}
|
||||||
|
|
||||||
|
from src.database import SessionLocal, Session as DbSession
|
||||||
|
|
||||||
|
# Accept BOTH the structured JSON args the tool schema advertises
|
||||||
|
# ({action, session_id, value}) AND the legacy line-based format
|
||||||
|
# (line1=action, line2=session_id, line3=value). Native function-calling
|
||||||
|
# models send JSON; fenced-block callers send lines. Previously only the
|
||||||
|
# line format was parsed, so a model that followed the schema (JSON) got
|
||||||
|
# "Need at least 2 lines" / "Rename needs line 3" and couldn't drive it.
|
||||||
|
_raw = (content or "").strip()
|
||||||
|
action = ""
|
||||||
|
target_sid = ""
|
||||||
|
value = None # the action param: new name (rename) / keep_count (truncate, fork)
|
||||||
|
_list_filter = ""
|
||||||
|
_parsed = None
|
||||||
|
if _raw.startswith("{"):
|
||||||
|
try:
|
||||||
|
_parsed = json.loads(_raw)
|
||||||
|
except Exception:
|
||||||
|
_parsed = None
|
||||||
|
if isinstance(_parsed, dict):
|
||||||
|
action = str(_parsed.get("action") or "").strip().lower()
|
||||||
|
target_sid = str(_parsed.get("session_id") or _parsed.get("session") or _parsed.get("id") or "").strip()
|
||||||
|
_v = _parsed.get("value")
|
||||||
|
if _v is None:
|
||||||
|
_v = (_parsed.get("name") or _parsed.get("new_name")
|
||||||
|
or _parsed.get("title") or _parsed.get("keep_count"))
|
||||||
|
value = None if _v is None else str(_v).strip()
|
||||||
|
_list_filter = str(_parsed.get("filter") or "").strip()
|
||||||
|
else:
|
||||||
|
lines = _raw.split("\n")
|
||||||
|
if not lines or not lines[0].strip():
|
||||||
|
return {"error": "Missing action (rename|archive|delete|important|truncate|fork|list|switch)"}
|
||||||
|
action = lines[0].strip().lower()
|
||||||
|
target_sid = lines[1].strip() if len(lines) >= 2 else ""
|
||||||
|
value = lines[2].strip() if len(lines) >= 3 else None
|
||||||
|
_list_filter = "\n".join(lines[1:]).strip()
|
||||||
|
|
||||||
|
if not action:
|
||||||
|
return {"error": "Missing action (rename|archive|delete|important|truncate|fork|list|switch)"}
|
||||||
|
|
||||||
|
# `list` alias - dispatch to list_sessions so the agent's natural
|
||||||
|
# first guess (every other manage_* tool has a `list` action) works.
|
||||||
|
if action == "list":
|
||||||
|
return await list_sessions(_list_filter, session_id, owner=owner)
|
||||||
|
|
||||||
|
if not target_sid:
|
||||||
|
return {"error": "Need a session_id (or 'current' for the active chat)"}
|
||||||
|
|
||||||
|
# Allow "current" to refer to the active session
|
||||||
|
if target_sid.lower() == "current" and session_id:
|
||||||
|
target_sid = session_id
|
||||||
|
|
||||||
|
# `switch` / `open` / `select` / `view` - the agent reaches for
|
||||||
|
# these when the user asks to "open" or "switch to" a session.
|
||||||
|
# There's no server-side way to make the browser navigate, so we
|
||||||
|
# just return a clickable anchor link the user can click. The
|
||||||
|
# frontend's chat-history click delegate routes `#session-<id>`
|
||||||
|
# to selectSession(). The agent's reply naturally embeds this
|
||||||
|
# result so the user sees a single clickable line.
|
||||||
|
def _session_query(db):
|
||||||
|
query = db.query(DbSession).filter(DbSession.id == target_sid)
|
||||||
|
if owner is not None:
|
||||||
|
query = query.filter(DbSession.owner == owner)
|
||||||
|
return query
|
||||||
|
|
||||||
|
if action in ("switch", "open", "select", "view"):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db_sess = _session_query(db).first()
|
||||||
|
if not db_sess:
|
||||||
|
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
||||||
|
name = db_sess.name or target_sid
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return {
|
||||||
|
"action": action,
|
||||||
|
"session_id": target_sid,
|
||||||
|
"name": name,
|
||||||
|
"results": f"[{name}](#session-{target_sid}) - click to open.",
|
||||||
|
}
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if action == "rename":
|
||||||
|
if not value:
|
||||||
|
return {"error": "rename needs a new name (the `value` arg, or line 3 in the legacy format)"}
|
||||||
|
new_name = value
|
||||||
|
db_sess = _session_query(db).first()
|
||||||
|
if not db_sess:
|
||||||
|
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
||||||
|
db_sess.name = new_name
|
||||||
|
db.commit()
|
||||||
|
_session_manager.update_session_name(target_sid, new_name)
|
||||||
|
return {"action": "rename", "session_id": target_sid, "name": new_name,
|
||||||
|
"results": f"Session renamed to '{new_name}'"}
|
||||||
|
|
||||||
|
elif action == "archive":
|
||||||
|
db_sess = _session_query(db).first()
|
||||||
|
if not db_sess:
|
||||||
|
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
||||||
|
db_sess.archived = True
|
||||||
|
db.commit()
|
||||||
|
return {"action": "archive", "session_id": target_sid,
|
||||||
|
"results": f"Session '{db_sess.name}' archived"}
|
||||||
|
|
||||||
|
elif action == "unarchive":
|
||||||
|
db_sess = _session_query(db).first()
|
||||||
|
if not db_sess:
|
||||||
|
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
||||||
|
db_sess.archived = False
|
||||||
|
db.commit()
|
||||||
|
return {"action": "unarchive", "session_id": target_sid,
|
||||||
|
"results": f"Session '{db_sess.name}' unarchived"}
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
if target_sid == session_id:
|
||||||
|
return {"error": "Cannot delete the current session while chatting in it. Delete other sessions first."}
|
||||||
|
db_sess = _session_query(db).first()
|
||||||
|
if not db_sess:
|
||||||
|
return {"error": f"Session '{target_sid}' not found. Refusing to delete an unknown chat id; use the exact id from list_sessions."}
|
||||||
|
if db_sess and db_sess.is_important:
|
||||||
|
return {"error": f"Session '{db_sess.name}' is starred/favorited. Unstar it first before deleting."}
|
||||||
|
try:
|
||||||
|
ok = _session_manager.delete_session(target_sid)
|
||||||
|
if not ok:
|
||||||
|
return {"error": f"Session '{target_sid}' was not deleted because it no longer exists."}
|
||||||
|
return {"action": "delete", "session_id": target_sid,
|
||||||
|
"results": f"Session '{db_sess.name or target_sid}' deleted"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to delete session: {e}"}
|
||||||
|
|
||||||
|
elif action in ("important", "unimportant"):
|
||||||
|
is_important = action == "important"
|
||||||
|
db_sess = _session_query(db).first()
|
||||||
|
if not db_sess:
|
||||||
|
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
||||||
|
# Prevent AI from unstarring sessions - only the user can do that manually
|
||||||
|
if not is_important and db_sess.is_important:
|
||||||
|
return {"error": f"Session '{db_sess.name}' is starred by the user. Only the user can unstar sessions manually."}
|
||||||
|
db_sess.is_important = is_important
|
||||||
|
db.commit()
|
||||||
|
status = "marked as important" if is_important else "unmarked as important"
|
||||||
|
return {"action": action, "session_id": target_sid,
|
||||||
|
"results": f"Session '{db_sess.name}' {status}"}
|
||||||
|
|
||||||
|
elif action == "truncate":
|
||||||
|
db_sess = _session_query(db).first()
|
||||||
|
if not db_sess:
|
||||||
|
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
||||||
|
keep_count = 10
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
keep_count = int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
success = _session_manager.truncate_messages(target_sid, keep_count)
|
||||||
|
if success:
|
||||||
|
return {"action": "truncate", "session_id": target_sid,
|
||||||
|
"results": f"Session truncated to last {keep_count} messages"}
|
||||||
|
return {"error": f"Failed to truncate session '{target_sid}'"}
|
||||||
|
|
||||||
|
elif action == "fork":
|
||||||
|
db_sess = _session_query(db).first()
|
||||||
|
if not db_sess:
|
||||||
|
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
||||||
|
keep_count = 0 # 0 = all messages
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
keep_count = int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
source = _session_manager.get_session(target_sid)
|
||||||
|
if not source:
|
||||||
|
return {"error": f"Session '{target_sid}' not found"}
|
||||||
|
|
||||||
|
new_sid = str(uuid.uuid4())[:8]
|
||||||
|
_session_manager.create_session(
|
||||||
|
session_id=new_sid,
|
||||||
|
name=f"Fork: {source.name}",
|
||||||
|
endpoint_url=source.endpoint_url,
|
||||||
|
model=source.model,
|
||||||
|
rag=False,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
# Copy messages
|
||||||
|
history = source.get_context_messages()
|
||||||
|
if keep_count > 0:
|
||||||
|
history = history[:keep_count]
|
||||||
|
from core.models import ChatMessage as InMemoryMsg
|
||||||
|
new_sess = _session_manager.get_session(new_sid)
|
||||||
|
for msg in history:
|
||||||
|
new_sess.add_message(InMemoryMsg(msg["role"], msg["content"]))
|
||||||
|
try:
|
||||||
|
from src.event_bus import fire_event
|
||||||
|
fire_event("session_created", owner)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||||
|
|
||||||
|
return {"action": "fork", "session_id": new_sid,
|
||||||
|
"source_session": target_sid, "messages_copied": len(history),
|
||||||
|
"results": f"Forked session '{source.name}' -> new session {new_sid} ({len(history)} messages)"}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown action '{action}'. Use: list, switch, rename, archive, unarchive, delete, important, unimportant, truncate, fork"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"manage_session failed: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Handler classes registered in TOOL_HANDLERS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class CreateSessionTool:
|
||||||
|
async def execute(self, content: str, ctx: dict) -> Dict:
|
||||||
|
return await create_session(content, ctx.get("session_id"), owner=ctx.get("owner"))
|
||||||
|
|
||||||
|
|
||||||
|
class ListSessionsTool:
|
||||||
|
async def execute(self, content: str, ctx: dict) -> Dict:
|
||||||
|
return await list_sessions(content, ctx.get("session_id"), owner=ctx.get("owner"))
|
||||||
|
|
||||||
|
|
||||||
|
class SendToSessionTool:
|
||||||
|
async def execute(self, content: str, ctx: dict) -> Dict:
|
||||||
|
return await send_to_session(content, ctx.get("session_id"), owner=ctx.get("owner"))
|
||||||
|
|
||||||
|
|
||||||
|
class ManageSessionTool:
|
||||||
|
async def execute(self, content: str, ctx: dict) -> Dict:
|
||||||
|
return await manage_session(content, ctx.get("session_id"), owner=ctx.get("owner"))
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ from src.constants import MAX_OUTPUT_CHARS
|
|||||||
class WebSearchTool:
|
class WebSearchTool:
|
||||||
async def execute(self, content: str, ctx: dict) -> dict:
|
async def execute(self, content: str, ctx: dict) -> dict:
|
||||||
from src.search import comprehensive_web_search
|
from src.search import comprehensive_web_search
|
||||||
|
progress_cb = ctx.get("progress_cb") if isinstance(ctx, dict) else None
|
||||||
raw = content.strip()
|
raw = content.strip()
|
||||||
query = raw
|
query = raw
|
||||||
time_filter = None
|
time_filter = None
|
||||||
@@ -37,6 +39,12 @@ class WebSearchTool:
|
|||||||
elif " news" in q_lc or q_lc.startswith("news ") or q_lc.endswith(" news"):
|
elif " news" in q_lc or q_lc.startswith("news ") or q_lc.endswith(" news"):
|
||||||
time_filter = "week"
|
time_filter = "week"
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
if progress_cb:
|
||||||
|
await progress_cb({
|
||||||
|
"elapsed_s": 0,
|
||||||
|
"tail": f"Searching web for: {query[:160]}",
|
||||||
|
})
|
||||||
|
try:
|
||||||
text, sources = await asyncio.wait_for(
|
text, sources = await asyncio.wait_for(
|
||||||
loop.run_in_executor(
|
loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
@@ -49,6 +57,21 @@ class WebSearchTool:
|
|||||||
),
|
),
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {
|
||||||
|
"error": f"web_search timed out after 30s: {query[:200]}",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"error": f"web_search failed: {type(e).__name__}: {str(e) or 'no details'}",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
if progress_cb:
|
||||||
|
await progress_cb({
|
||||||
|
"elapsed_s": 30,
|
||||||
|
"tail": "Search completed; preparing sources.",
|
||||||
|
})
|
||||||
output = text[:MAX_OUTPUT_CHARS] if len(text) > MAX_OUTPUT_CHARS else text
|
output = text[:MAX_OUTPUT_CHARS] if len(text) > MAX_OUTPUT_CHARS else text
|
||||||
if sources:
|
if sources:
|
||||||
output += "\n\n<!-- SOURCES:" + json.dumps(sources) + " -->"
|
output += "\n\n<!-- SOURCES:" + json.dumps(sources) + " -->"
|
||||||
@@ -87,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:
|
||||||
|
|||||||
+32
-459
@@ -1,17 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
ai_interaction.py
|
ai_interaction.py
|
||||||
|
|
||||||
AI-to-AI interaction tools: create_session, list_sessions, send_to_session,
|
AI-to-AI interaction tools: pipeline and manage_memory, plus shared model
|
||||||
pipeline, plus shared model resolution (_resolve_model).
|
resolution (_resolve_model), the session-manager singleton, and dispatch_ai_tool.
|
||||||
|
|
||||||
chat_with_model, ask_teacher and list_models were moved to
|
As part of the tool -> registry migration (#3629), chat_with_model, ask_teacher
|
||||||
src/agent_tools/model_interaction_tools.py as part of the tool -> registry
|
and list_models moved to src/agent_tools/model_interaction_tools.py, and
|
||||||
migration (#3629); they still reuse _resolve_model / AI_CHAT_TIMEOUT from here.
|
create_session, list_sessions, send_to_session and manage_session moved to
|
||||||
|
src/agent_tools/session_tools.py. Those modules reuse get_session_manager /
|
||||||
|
_resolve_model / AI_CHAT_TIMEOUT from here.
|
||||||
|
|
||||||
These are agent tools — the LLM writes fenced code blocks and they execute
|
These are agent tools — the LLM writes fenced code blocks and they execute
|
||||||
through the standard agent_tools.py pipeline.
|
through the standard agent_tools.py pipeline.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
@@ -132,7 +135,8 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
|||||||
r = httpx.get(models_url, headers=headers, timeout=5)
|
r = httpx.get(models_url, headers=headers, timeout=5)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
items = data if isinstance(data, list) else (data.get("data") or [])
|
||||||
|
model_ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||||
if not model_ids:
|
if not model_ids:
|
||||||
model_ids = [
|
model_ids = [
|
||||||
m.get("name") or m.get("model")
|
m.get("name") or m.get("model")
|
||||||
@@ -165,204 +169,6 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def do_create_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""Create a new chat session.
|
|
||||||
|
|
||||||
Content format:
|
|
||||||
Line 1: session name
|
|
||||||
Line 2: model_name (or model_name@endpoint_name)
|
|
||||||
"""
|
|
||||||
if not _session_manager:
|
|
||||||
return {"error": "Session manager not available"}
|
|
||||||
|
|
||||||
lines = content.strip().split("\n")
|
|
||||||
if len(lines) < 2:
|
|
||||||
return {"error": "Need 2 lines: session name, then model spec"}
|
|
||||||
|
|
||||||
name = lines[0].strip()
|
|
||||||
model_spec = lines[1].strip()
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
return {"error": "Session name cannot be empty"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
url, model, headers = _resolve_model(model_spec, owner=owner)
|
|
||||||
except ValueError as e:
|
|
||||||
return {"error": str(e)}
|
|
||||||
|
|
||||||
sid = str(uuid.uuid4())[:8]
|
|
||||||
try:
|
|
||||||
_session_manager.create_session(
|
|
||||||
session_id=sid,
|
|
||||||
name=name,
|
|
||||||
endpoint_url=url,
|
|
||||||
model=model,
|
|
||||||
rag=False,
|
|
||||||
owner=owner,
|
|
||||||
)
|
|
||||||
# Store headers on session for future calls
|
|
||||||
sess = _session_manager.get_session(sid)
|
|
||||||
if sess and headers:
|
|
||||||
sess.headers = headers
|
|
||||||
try:
|
|
||||||
from src.event_bus import fire_event
|
|
||||||
fire_event("session_created", owner)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("session_created event dispatch failed", exc_info=True)
|
|
||||||
|
|
||||||
return {"session_id": sid, "name": name, "model": model, "endpoint_url": url}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"create_session failed: {e}")
|
|
||||||
return {"error": f"Failed to create session: {e}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def do_list_sessions(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""List sessions sorted by most-recently-active first.
|
|
||||||
|
|
||||||
Output includes a relative "last active" timestamp per row so the
|
|
||||||
agent can answer "open my last chat" without guessing from titles.
|
|
||||||
The most-recent session is always first in the list.
|
|
||||||
|
|
||||||
Content = optional filter keyword (matches session name).
|
|
||||||
"""
|
|
||||||
if not _session_manager:
|
|
||||||
return {"error": "Session manager not available"}
|
|
||||||
|
|
||||||
keyword = content.strip().lower() if content.strip() else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
from core.database import SessionLocal, Session as DbSession
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
# Pull every session's last_accessed from the DB so we can sort
|
|
||||||
# by recency. In-memory sessions hold name + model + msg_count;
|
|
||||||
# the DB row holds the timestamps.
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
db_rows = {r.id: r for r in db.query(DbSession).all()}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# SECURITY: scope to the caller's sessions. Passing None returned
|
|
||||||
# every user's sessions, which the agent tool then exposed via the
|
|
||||||
# "list my chats" reply.
|
|
||||||
sessions = _session_manager.get_sessions_for_user(owner)
|
|
||||||
rows = []
|
|
||||||
for sid, sess in sessions.items():
|
|
||||||
if keyword and keyword not in (sess.name or "").lower():
|
|
||||||
continue
|
|
||||||
db_row = db_rows.get(sid)
|
|
||||||
# Prefer last_accessed; fall back to updated_at, then created_at.
|
|
||||||
ts = None
|
|
||||||
if db_row:
|
|
||||||
ts = getattr(db_row, 'last_accessed', None) or getattr(db_row, 'updated_at', None) or getattr(db_row, 'created_at', None)
|
|
||||||
rows.append((ts, sid, sess))
|
|
||||||
|
|
||||||
# Sort by timestamp DESC; rows without a timestamp sink to the bottom.
|
|
||||||
rows.sort(key=lambda r: r[0] or datetime.min, reverse=True)
|
|
||||||
|
|
||||||
def _rel(ts):
|
|
||||||
if not ts:
|
|
||||||
return 'never'
|
|
||||||
now = datetime.utcnow()
|
|
||||||
try:
|
|
||||||
if ts.tzinfo is not None:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
diff = (now - ts).total_seconds()
|
|
||||||
except Exception:
|
|
||||||
return 'unknown'
|
|
||||||
if diff < 60: return 'just now'
|
|
||||||
if diff < 3600: return f'{int(diff / 60)}m ago'
|
|
||||||
if diff < 86400: return f'{int(diff / 3600)}h ago'
|
|
||||||
if diff < 86400 * 7: return f'{int(diff / 86400)}d ago'
|
|
||||||
return ts.strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
for i, (ts, sid, sess) in enumerate(rows):
|
|
||||||
if i >= 50:
|
|
||||||
lines.append(f"... and {len(rows) - 50} more (showing first 50)")
|
|
||||||
break
|
|
||||||
safe_name = (sess.name or "Untitled").replace("[", "\\[").replace("]", "\\]")
|
|
||||||
msg_count = getattr(sess, "message_count", 0) or 0
|
|
||||||
model = getattr(sess, "model", "unknown")
|
|
||||||
marker = " ← most recent" if i == 0 else ""
|
|
||||||
lines.append(f"- **[{safe_name}](#session-{sid})** (id: `{sid}`, model: {model}, {msg_count} msgs, last active {_rel(ts)}){marker}")
|
|
||||||
|
|
||||||
if not lines:
|
|
||||||
return {"results": "No sessions found" + (f" matching '{keyword}'" if keyword else "") + "."}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"results": (
|
|
||||||
f"Found {len(rows)} session(s), sorted most-recent first:\n"
|
|
||||||
+ "\n".join(lines)
|
|
||||||
+ "\n\nAssistant: when replying to the user, preserve the chat-title markdown links exactly as shown, e.g. `[Chat](#session-id)`. Do not rewrite this as a plain, non-clickable table."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"list_sessions failed: {e}")
|
|
||||||
return {"error": str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
async def do_send_to_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""Send a message to an existing session and get a response.
|
|
||||||
|
|
||||||
Content format:
|
|
||||||
Line 1: session_id
|
|
||||||
Line 2+: message
|
|
||||||
"""
|
|
||||||
from src.llm_core import llm_call_async
|
|
||||||
from core.models import ChatMessage
|
|
||||||
|
|
||||||
if not _session_manager:
|
|
||||||
return {"error": "Session manager not available"}
|
|
||||||
|
|
||||||
lines = content.strip().split("\n", 1)
|
|
||||||
if len(lines) < 2:
|
|
||||||
return {"error": "Need 2 lines: session_id, then message"}
|
|
||||||
|
|
||||||
target_sid = lines[0].strip()
|
|
||||||
message = lines[1].strip()
|
|
||||||
|
|
||||||
sess = _session_manager.get_session(target_sid)
|
|
||||||
if not sess:
|
|
||||||
return {"error": f"Session '{target_sid}' not found"}
|
|
||||||
|
|
||||||
# Owner-scope: reject access to another user's session
|
|
||||||
if owner and getattr(sess, "owner", None) and sess.owner != owner:
|
|
||||||
return {"error": f"Session '{target_sid}' not found"}
|
|
||||||
|
|
||||||
if not message:
|
|
||||||
return {"error": "No message provided"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Build context from session history
|
|
||||||
context = sess.get_context_messages()
|
|
||||||
context.append({"role": "user", "content": message})
|
|
||||||
|
|
||||||
response = await llm_call_async(
|
|
||||||
sess.endpoint_url, sess.model, context,
|
|
||||||
headers=sess.headers,
|
|
||||||
timeout=AI_CHAT_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save both messages to session
|
|
||||||
sess.add_message(ChatMessage("user", message))
|
|
||||||
sess.add_message(ChatMessage("assistant", response))
|
|
||||||
|
|
||||||
# Truncate for tool output
|
|
||||||
if len(response) > 10000:
|
|
||||||
response = response[:10000] + "\n... (truncated)"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"session_id": target_sid,
|
|
||||||
"session_name": sess.name,
|
|
||||||
"response": response,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"send_to_session failed: {e}")
|
|
||||||
return {"error": f"Failed to send to session: {e}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def stream_ai_tool(tool: str, content: str, session_id: Optional[str] = None, owner: Optional[str] = None):
|
async def stream_ai_tool(tool: str, content: str, session_id: Optional[str] = None, owner: Optional[str] = None):
|
||||||
"""Dispatcher for streaming AI tools. Yields events as async generator."""
|
"""Dispatcher for streaming AI tools. Yields events as async generator."""
|
||||||
# Fallback: run non-streaming and yield final result
|
# Fallback: run non-streaming and yield final result
|
||||||
@@ -424,7 +230,7 @@ async def do_pipeline(content: str, session_id: Optional[str] = None, owner: Opt
|
|||||||
if not model_spec or not instruction:
|
if not model_spec or not instruction:
|
||||||
return {"error": f"Step {i + 1}: both 'model' and 'instruction' are required"}
|
return {"error": f"Step {i + 1}: both 'model' and 'instruction' are required"}
|
||||||
try:
|
try:
|
||||||
url, model, headers = _resolve_model(model_spec, owner=owner)
|
url, model, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
|
||||||
resolved.append((url, model, headers, instruction))
|
resolved.append((url, model, headers, instruction))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {"error": f"Step {i + 1}: {e}"}
|
return {"error": f"Step {i + 1}: {e}"}
|
||||||
@@ -483,229 +289,6 @@ async def do_pipeline(content: str, session_id: Optional[str] = None, owner: Opt
|
|||||||
# Session management tool
|
# Session management tool
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def do_manage_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""Manage sessions: rename, archive, delete, important, truncate, fork.
|
|
||||||
|
|
||||||
Content format:
|
|
||||||
Line 1: action (rename|archive|unarchive|delete|important|unimportant|truncate|fork)
|
|
||||||
Line 2: target session_id (or "current" to use the active session)
|
|
||||||
Line 3+: action-specific params (e.g. new name for rename, keep_count for truncate)
|
|
||||||
"""
|
|
||||||
if not _session_manager:
|
|
||||||
return {"error": "Session manager not available"}
|
|
||||||
|
|
||||||
from src.database import SessionLocal, Session as DbSession
|
|
||||||
|
|
||||||
# Accept BOTH the structured JSON args the tool schema advertises
|
|
||||||
# ({action, session_id, value}) AND the legacy line-based format
|
|
||||||
# (line1=action, line2=session_id, line3=value). Native function-calling
|
|
||||||
# models send JSON; fenced-block callers send lines. Previously only the
|
|
||||||
# line format was parsed, so a model that followed the schema (JSON) got
|
|
||||||
# "Need at least 2 lines" / "Rename needs line 3" and couldn't drive it.
|
|
||||||
_raw = (content or "").strip()
|
|
||||||
action = ""
|
|
||||||
target_sid = ""
|
|
||||||
value = None # the action param: new name (rename) / keep_count (truncate, fork)
|
|
||||||
_list_filter = ""
|
|
||||||
_parsed = None
|
|
||||||
if _raw.startswith("{"):
|
|
||||||
try:
|
|
||||||
_parsed = json.loads(_raw)
|
|
||||||
except Exception:
|
|
||||||
_parsed = None
|
|
||||||
if isinstance(_parsed, dict):
|
|
||||||
action = str(_parsed.get("action") or "").strip().lower()
|
|
||||||
target_sid = str(_parsed.get("session_id") or _parsed.get("session") or _parsed.get("id") or "").strip()
|
|
||||||
_v = _parsed.get("value")
|
|
||||||
if _v is None:
|
|
||||||
_v = (_parsed.get("name") or _parsed.get("new_name")
|
|
||||||
or _parsed.get("title") or _parsed.get("keep_count"))
|
|
||||||
value = None if _v is None else str(_v).strip()
|
|
||||||
_list_filter = str(_parsed.get("filter") or "").strip()
|
|
||||||
else:
|
|
||||||
lines = _raw.split("\n")
|
|
||||||
if not lines or not lines[0].strip():
|
|
||||||
return {"error": "Missing action (rename|archive|delete|important|truncate|fork|list|switch)"}
|
|
||||||
action = lines[0].strip().lower()
|
|
||||||
target_sid = lines[1].strip() if len(lines) >= 2 else ""
|
|
||||||
value = lines[2].strip() if len(lines) >= 3 else None
|
|
||||||
_list_filter = "\n".join(lines[1:]).strip()
|
|
||||||
|
|
||||||
if not action:
|
|
||||||
return {"error": "Missing action (rename|archive|delete|important|truncate|fork|list|switch)"}
|
|
||||||
|
|
||||||
# `list` alias — dispatch to do_list_sessions so the agent's natural
|
|
||||||
# first guess (every other manage_* tool has a `list` action) works.
|
|
||||||
if action == "list":
|
|
||||||
return await do_list_sessions(_list_filter, session_id, owner=owner)
|
|
||||||
|
|
||||||
if not target_sid:
|
|
||||||
return {"error": "Need a session_id (or 'current' for the active chat)"}
|
|
||||||
|
|
||||||
# Allow "current" to refer to the active session
|
|
||||||
if target_sid.lower() == "current" and session_id:
|
|
||||||
target_sid = session_id
|
|
||||||
|
|
||||||
# `switch` / `open` / `select` / `view` — the agent reaches for
|
|
||||||
# these when the user asks to "open" or "switch to" a session.
|
|
||||||
# There's no server-side way to make the browser navigate, so we
|
|
||||||
# just return a clickable anchor link the user can click. The
|
|
||||||
# frontend's chat-history click delegate routes `#session-<id>`
|
|
||||||
# to selectSession(). The agent's reply naturally embeds this
|
|
||||||
# result so the user sees a single clickable line.
|
|
||||||
def _session_query(db):
|
|
||||||
query = db.query(DbSession).filter(DbSession.id == target_sid)
|
|
||||||
if owner is not None:
|
|
||||||
query = query.filter(DbSession.owner == owner)
|
|
||||||
return query
|
|
||||||
|
|
||||||
if action in ("switch", "open", "select", "view"):
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
db_sess = _session_query(db).first()
|
|
||||||
if not db_sess:
|
|
||||||
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
|
||||||
name = db_sess.name or target_sid
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
return {
|
|
||||||
"action": action,
|
|
||||||
"session_id": target_sid,
|
|
||||||
"name": name,
|
|
||||||
"results": f"[{name}](#session-{target_sid}) — click to open.",
|
|
||||||
}
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
if action == "rename":
|
|
||||||
if not value:
|
|
||||||
return {"error": "rename needs a new name (the `value` arg, or line 3 in the legacy format)"}
|
|
||||||
new_name = value
|
|
||||||
db_sess = _session_query(db).first()
|
|
||||||
if not db_sess:
|
|
||||||
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
|
||||||
db_sess.name = new_name
|
|
||||||
db.commit()
|
|
||||||
_session_manager.update_session_name(target_sid, new_name)
|
|
||||||
return {"action": "rename", "session_id": target_sid, "name": new_name,
|
|
||||||
"results": f"Session renamed to '{new_name}'"}
|
|
||||||
|
|
||||||
elif action == "archive":
|
|
||||||
db_sess = _session_query(db).first()
|
|
||||||
if not db_sess:
|
|
||||||
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
|
||||||
db_sess.archived = True
|
|
||||||
db.commit()
|
|
||||||
return {"action": "archive", "session_id": target_sid,
|
|
||||||
"results": f"Session '{db_sess.name}' archived"}
|
|
||||||
|
|
||||||
elif action == "unarchive":
|
|
||||||
db_sess = _session_query(db).first()
|
|
||||||
if not db_sess:
|
|
||||||
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
|
||||||
db_sess.archived = False
|
|
||||||
db.commit()
|
|
||||||
return {"action": "unarchive", "session_id": target_sid,
|
|
||||||
"results": f"Session '{db_sess.name}' unarchived"}
|
|
||||||
|
|
||||||
elif action == "delete":
|
|
||||||
if target_sid == session_id:
|
|
||||||
return {"error": "Cannot delete the current session while chatting in it. Delete other sessions first."}
|
|
||||||
db_sess = _session_query(db).first()
|
|
||||||
if not db_sess:
|
|
||||||
return {"error": f"Session '{target_sid}' not found. Refusing to delete an unknown chat id; use the exact id from list_sessions."}
|
|
||||||
if db_sess and db_sess.is_important:
|
|
||||||
return {"error": f"Session '{db_sess.name}' is starred/favorited. Unstar it first before deleting."}
|
|
||||||
try:
|
|
||||||
ok = _session_manager.delete_session(target_sid)
|
|
||||||
if not ok:
|
|
||||||
return {"error": f"Session '{target_sid}' was not deleted because it no longer exists."}
|
|
||||||
return {"action": "delete", "session_id": target_sid,
|
|
||||||
"results": f"Session '{db_sess.name or target_sid}' deleted"}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"Failed to delete session: {e}"}
|
|
||||||
|
|
||||||
elif action in ("important", "unimportant"):
|
|
||||||
is_important = action == "important"
|
|
||||||
db_sess = _session_query(db).first()
|
|
||||||
if not db_sess:
|
|
||||||
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
|
||||||
# Prevent AI from unstarring sessions — only the user can do that manually
|
|
||||||
if not is_important and db_sess.is_important:
|
|
||||||
return {"error": f"Session '{db_sess.name}' is starred by the user. Only the user can unstar sessions manually."}
|
|
||||||
db_sess.is_important = is_important
|
|
||||||
db.commit()
|
|
||||||
status = "marked as important" if is_important else "unmarked as important"
|
|
||||||
return {"action": action, "session_id": target_sid,
|
|
||||||
"results": f"Session '{db_sess.name}' {status}"}
|
|
||||||
|
|
||||||
elif action == "truncate":
|
|
||||||
db_sess = _session_query(db).first()
|
|
||||||
if not db_sess:
|
|
||||||
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
|
||||||
keep_count = 10
|
|
||||||
if value:
|
|
||||||
try:
|
|
||||||
keep_count = int(value)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
success = _session_manager.truncate_messages(target_sid, keep_count)
|
|
||||||
if success:
|
|
||||||
return {"action": "truncate", "session_id": target_sid,
|
|
||||||
"results": f"Session truncated to last {keep_count} messages"}
|
|
||||||
return {"error": f"Failed to truncate session '{target_sid}'"}
|
|
||||||
|
|
||||||
elif action == "fork":
|
|
||||||
db_sess = _session_query(db).first()
|
|
||||||
if not db_sess:
|
|
||||||
return {"error": f"Session '{target_sid}' not found. Use list_sessions and pass the exact id it returned."}
|
|
||||||
keep_count = 0 # 0 = all messages
|
|
||||||
if value:
|
|
||||||
try:
|
|
||||||
keep_count = int(value)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
source = _session_manager.get_session(target_sid)
|
|
||||||
if not source:
|
|
||||||
return {"error": f"Session '{target_sid}' not found"}
|
|
||||||
|
|
||||||
new_sid = str(uuid.uuid4())[:8]
|
|
||||||
_session_manager.create_session(
|
|
||||||
session_id=new_sid,
|
|
||||||
name=f"Fork: {source.name}",
|
|
||||||
endpoint_url=source.endpoint_url,
|
|
||||||
model=source.model,
|
|
||||||
rag=False,
|
|
||||||
owner=owner,
|
|
||||||
)
|
|
||||||
# Copy messages
|
|
||||||
history = source.get_context_messages()
|
|
||||||
if keep_count > 0:
|
|
||||||
history = history[:keep_count]
|
|
||||||
from core.models import ChatMessage as InMemoryMsg
|
|
||||||
new_sess = _session_manager.get_session(new_sid)
|
|
||||||
for msg in history:
|
|
||||||
new_sess.add_message(InMemoryMsg(msg["role"], msg["content"]))
|
|
||||||
try:
|
|
||||||
from src.event_bus import fire_event
|
|
||||||
fire_event("session_created", owner)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("session_created event dispatch failed", exc_info=True)
|
|
||||||
|
|
||||||
return {"action": "fork", "session_id": new_sid,
|
|
||||||
"source_session": target_sid, "messages_copied": len(history),
|
|
||||||
"results": f"Forked session '{source.name}' -> new session {new_sid} ({len(history)} messages)"}
|
|
||||||
|
|
||||||
else:
|
|
||||||
return {"error": f"Unknown action '{action}'. Use: list, switch, rename, archive, unarchive, delete, important, unimportant, truncate, fork"}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"manage_session failed: {e}")
|
|
||||||
return {"error": str(e)}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Memory management tool
|
# Memory management tool
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -850,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}'."}
|
||||||
@@ -872,8 +465,6 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
|||||||
return {"error": f"Unknown action '{action}'. Use: list, add, edit, delete, search"}
|
return {"error": f"Unknown action '{action}'. Use: list, add, edit, delete, search"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# RAG management tool
|
# RAG management tool
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1044,7 +635,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
|||||||
|
|
||||||
# Resolve the model to validate it exists
|
# Resolve the model to validate it exists
|
||||||
try:
|
try:
|
||||||
url, model_id, headers = _resolve_model(model_spec, owner=owner)
|
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
@@ -1334,7 +925,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
if not model_spec:
|
if not model_spec:
|
||||||
for candidate in ("gpt-image-1.5", "gpt-image-1", "dall-e-3"):
|
for candidate in ("gpt-image-1.5", "gpt-image-1", "dall-e-3"):
|
||||||
try:
|
try:
|
||||||
_resolve_model(candidate, owner=owner)
|
await asyncio.to_thread(_resolve_model, candidate, owner=owner)
|
||||||
model_spec = candidate
|
model_spec = candidate
|
||||||
break
|
break
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -1361,7 +952,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
try:
|
try:
|
||||||
_r = _req.get(_ibase + "/models", timeout=3)
|
_r = _req.get(_ibase + "/models", timeout=3)
|
||||||
_r.raise_for_status()
|
_r.raise_for_status()
|
||||||
_mids = [m.get("id") for m in (_r.json().get("data") or []) if m.get("id")]
|
_data = _r.json()
|
||||||
|
_ditems = _data if isinstance(_data, list) else (_data.get("data") or [])
|
||||||
|
_mids = [m.get("id") for m in _ditems if isinstance(m, dict) and m.get("id")]
|
||||||
if _mids:
|
if _mids:
|
||||||
model_spec = _mids[0]
|
model_spec = _mids[0]
|
||||||
break
|
break
|
||||||
@@ -1376,7 +969,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
|
|
||||||
# Resolve the model to find the right endpoint
|
# Resolve the model to find the right endpoint
|
||||||
try:
|
try:
|
||||||
url, model_id, headers = _resolve_model(model_spec, owner=owner)
|
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return {"error": f"No endpoint found with image model '{model_spec}'. "
|
return {"error": f"No endpoint found with image model '{model_spec}'. "
|
||||||
"Configure an OpenAI-compatible endpoint with image generation support."}
|
"Configure an OpenAI-compatible endpoint with image generation support."}
|
||||||
@@ -1522,30 +1115,10 @@ async def dispatch_ai_tool(
|
|||||||
) -> Tuple[str, Dict]:
|
) -> Tuple[str, Dict]:
|
||||||
"""Dispatch an AI interaction tool. Returns (description, result_dict)."""
|
"""Dispatch an AI interaction tool. Returns (description, result_dict)."""
|
||||||
|
|
||||||
if tool == "create_session":
|
if tool == "pipeline":
|
||||||
name = content.split("\n")[0].strip()[:60]
|
|
||||||
desc = f"create_session: {name}"
|
|
||||||
result = await do_create_session(content, session_id, owner=owner)
|
|
||||||
|
|
||||||
elif tool == "list_sessions":
|
|
||||||
keyword = content.strip()[:40]
|
|
||||||
desc = f"list_sessions{': ' + keyword if keyword else ''}"
|
|
||||||
result = await do_list_sessions(content, session_id, owner=owner)
|
|
||||||
|
|
||||||
elif tool == "send_to_session":
|
|
||||||
sid = content.split("\n")[0].strip()[:20]
|
|
||||||
desc = f"send_to_session: {sid}"
|
|
||||||
result = await do_send_to_session(content, session_id, owner=owner)
|
|
||||||
|
|
||||||
elif tool == "pipeline":
|
|
||||||
desc = "pipeline: running steps"
|
desc = "pipeline: running steps"
|
||||||
result = await do_pipeline(content, session_id, owner=owner)
|
result = await do_pipeline(content, session_id, owner=owner)
|
||||||
|
|
||||||
elif tool == "manage_session":
|
|
||||||
action = content.split("\n")[0].strip()[:40]
|
|
||||||
desc = f"manage_session: {action}"
|
|
||||||
result = await do_manage_session(content, session_id, owner=owner)
|
|
||||||
|
|
||||||
elif tool == "manage_memory":
|
elif tool == "manage_memory":
|
||||||
action = content.split("\n")[0].strip()[:40]
|
action = content.split("\n")[0].strip()[:40]
|
||||||
desc = f"manage_memory: {action}"
|
desc = f"manage_memory: {action}"
|
||||||
|
|||||||
+16
-1
@@ -81,11 +81,26 @@ class APIKeyManager:
|
|||||||
keys stay encrypted. Loading via load() first would decrypt them and
|
keys stay encrypted. Loading via load() first would decrypt them and
|
||||||
write them back as plaintext, which then fails to decrypt on the next
|
write them back as plaintext, which then fails to decrypt on the next
|
||||||
load() and silently drops those providers.
|
load() and silently drops those providers.
|
||||||
|
|
||||||
|
Uses atomic write (temp file + os.replace) so a crash, disk-full, or
|
||||||
|
mid-write error never truncates the existing keys file.
|
||||||
"""
|
"""
|
||||||
keys = self._load_raw()
|
keys = self._load_raw()
|
||||||
keys[provider] = self.encrypt_api_key(api_key)
|
keys[provider] = self.encrypt_api_key(api_key)
|
||||||
with open(self.api_keys_file, 'w', encoding="utf-8") as f:
|
tmp_file = self.api_keys_file + ".tmp"
|
||||||
|
try:
|
||||||
|
with open(tmp_file, 'w', encoding="utf-8") as f:
|
||||||
json.dump(keys, f)
|
json.dump(keys, f)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp_file, self.api_keys_file)
|
||||||
|
except OSError:
|
||||||
|
# Clean up temp file on failure; re-raise so callers see the error
|
||||||
|
try:
|
||||||
|
os.remove(tmp_file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
def load(self) -> Dict[str, str]:
|
def load(self) -> Dict[str, str]:
|
||||||
"""Load and decrypt API keys"""
|
"""Load and decrypt API keys"""
|
||||||
|
|||||||
+30
-1
@@ -1,6 +1,13 @@
|
|||||||
# src/app_helpers.py
|
# src/app_helpers.py
|
||||||
import os
|
|
||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def read_if_exists(path: str) -> str:
|
def read_if_exists(path: str) -> str:
|
||||||
"""Read file if it exists, return empty string otherwise."""
|
"""Read file if it exists, return empty string otherwise."""
|
||||||
@@ -20,6 +27,28 @@ def abs_join(base_dir: str, rel: str) -> str:
|
|||||||
"""Join paths and return absolute path."""
|
"""Join paths and return absolute path."""
|
||||||
return os.path.abspath(os.path.join(base_dir, rel))
|
return os.path.abspath(os.path.join(base_dir, rel))
|
||||||
|
|
||||||
|
def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
|
||||||
|
"""Read an app-bundled HTML page and inject the CSP nonce into inline <script> tags.
|
||||||
|
|
||||||
|
Callers pass fixed, server-owned template paths (index/login/backgrounds),
|
||||||
|
never a client-supplied path. So any read failure here — a missing file
|
||||||
|
(broken deployment) or a permission/IO error — is a server fault, not a
|
||||||
|
client "not found": map all of them to a logged 500 so a missing core
|
||||||
|
template surfaces in 5xx alerting instead of hiding behind a 404. If a
|
||||||
|
future caller serves a client-influenced path where 404 is correct, branch
|
||||||
|
that at the call site rather than defaulting this shared helper to 404.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
html = f.read()
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Failed to read page %s", file_path)
|
||||||
|
raise HTTPException(500, "Internal server error")
|
||||||
|
nonce = getattr(request.state, "csp_nonce", "")
|
||||||
|
html = html.replace("{{CSP_NONCE}}", nonce)
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
def inside_base_dir(base_dir: str, path: str) -> bool:
|
def inside_base_dir(base_dir: str, path: str) -> bool:
|
||||||
"""Check if path is inside base directory."""
|
"""Check if path is inside base directory."""
|
||||||
if not isinstance(base_dir, str) or not isinstance(path, str):
|
if not isinstance(base_dir, str) or not isinstance(path, str):
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from src.model_discovery import ModelDiscovery
|
|||||||
from src.chat_handler import ChatHandler
|
from src.chat_handler import ChatHandler
|
||||||
from src.research_handler import ResearchHandler
|
from src.research_handler import ResearchHandler
|
||||||
from src.upload_handler import UploadHandler
|
from src.upload_handler import UploadHandler
|
||||||
|
from src.tool_utils import set_upload_handler
|
||||||
from src.search import update_search_config
|
from src.search import update_search_config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -49,6 +50,8 @@ def initialize_managers(base_dir: str, rag_manager=None) -> Dict[str, Any]:
|
|||||||
session_manager = SessionManager(SESSIONS_FILE)
|
session_manager = SessionManager(SESSIONS_FILE)
|
||||||
set_session_manager(session_manager) # Enable Session.add_message() persistence
|
set_session_manager(session_manager) # Enable Session.add_message() persistence
|
||||||
upload_handler = UploadHandler(base_dir, UPLOAD_DIR)
|
upload_handler = UploadHandler(base_dir, UPLOAD_DIR)
|
||||||
|
session_manager.upload_handler = upload_handler
|
||||||
|
set_upload_handler(upload_handler)
|
||||||
personal_docs_manager = PersonalDocsManager(PERSONAL_DIR, rag_manager)
|
personal_docs_manager = PersonalDocsManager(PERSONAL_DIR, rag_manager)
|
||||||
api_key_manager = APIKeyManager(DATA_DIR)
|
api_key_manager = APIKeyManager(DATA_DIR)
|
||||||
preset_manager = PresetManager(DATA_DIR)
|
preset_manager = PresetManager(DATA_DIR)
|
||||||
@@ -68,8 +71,10 @@ def initialize_managers(base_dir: str, rag_manager=None) -> Dict[str, Any]:
|
|||||||
logger.info(f"Rebuilt memory vector index from {len(existing)} existing entries")
|
logger.info(f"Rebuilt memory vector index from {len(existing)} existing entries")
|
||||||
logger.info("MemoryVectorStore initialized")
|
logger.info("MemoryVectorStore initialized")
|
||||||
else:
|
else:
|
||||||
|
# Keep the unhealthy object (do NOT reset to None): consumers gate on
|
||||||
|
# `.healthy`, and service_health.chromadb_health() needs a present
|
||||||
|
# object to report DEGRADED/DOWN instead of DISABLED ("not configured").
|
||||||
logger.warning("MemoryVectorStore DEGRADED: ChromaDB vector memory unavailable")
|
logger.warning("MemoryVectorStore DEGRADED: ChromaDB vector memory unavailable")
|
||||||
memory_vector = None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"MemoryVectorStore DEGRADED: {e}")
|
logger.warning(f"MemoryVectorStore DEGRADED: {e}")
|
||||||
memory_vector = None
|
memory_vector = None
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Attachment reference helpers for chat storage and tool manifests.
|
||||||
|
|
||||||
|
Live model calls may need provider-specific data URLs for the current turn.
|
||||||
|
Persisted history and search indexes should keep stable upload references and
|
||||||
|
human-readable text instead of duplicating raw media bytes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
|
||||||
|
DATA_URL_RE = re.compile(
|
||||||
|
r"data:[^;,\s\"']+;base64,[A-Za-z0-9+/=]+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
MEDIA_BLOCK_TYPES = {
|
||||||
|
"image",
|
||||||
|
"image_url",
|
||||||
|
"input_image",
|
||||||
|
"audio",
|
||||||
|
"input_audio",
|
||||||
|
"file",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def strip_inline_data_urls(text: str) -> str:
|
||||||
|
"""Replace inline data URLs with a compact marker."""
|
||||||
|
if not isinstance(text, str) or ";base64," not in text:
|
||||||
|
return text
|
||||||
|
return DATA_URL_RE.sub("[inline media omitted from persisted history]", text)
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_ref(info: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return the stable attachment reference shape used outside raw uploads."""
|
||||||
|
upload_id = str(info.get("id") or info.get("attachment_id") or "").strip()
|
||||||
|
try:
|
||||||
|
size = int(info.get("size") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
size = 0
|
||||||
|
ref = {
|
||||||
|
"type": "attachment_ref",
|
||||||
|
"attachment_id": upload_id,
|
||||||
|
"name": info.get("name") or info.get("original_name") or upload_id,
|
||||||
|
"mime": info.get("mime") or "application/octet-stream",
|
||||||
|
"size": size,
|
||||||
|
}
|
||||||
|
checksum = info.get("checksum_sha256") or info.get("sha256") or info.get("hash")
|
||||||
|
if checksum:
|
||||||
|
ref["checksum_sha256"] = checksum
|
||||||
|
created_at = info.get("created_at") or info.get("uploaded_at")
|
||||||
|
if created_at:
|
||||||
|
ref["created_at"] = created_at
|
||||||
|
for key in ("width", "height", "vision", "vision_model", "gallery_id"):
|
||||||
|
value = info.get(key)
|
||||||
|
if value is not None:
|
||||||
|
ref[key] = value
|
||||||
|
return ref
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_refs_from_metadata(metadata: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||||
|
"""Extract attachment refs from message metadata."""
|
||||||
|
attachments = (metadata or {}).get("attachments") or []
|
||||||
|
if not isinstance(attachments, list):
|
||||||
|
return []
|
||||||
|
refs: list[dict[str, Any]] = []
|
||||||
|
for item in attachments:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
ref = attachment_ref(item)
|
||||||
|
if ref.get("attachment_id"):
|
||||||
|
refs.append(ref)
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_line(ref: dict[str, Any]) -> str:
|
||||||
|
parts = [f"Attachment: {ref.get('name') or ref.get('attachment_id') or 'upload'}"]
|
||||||
|
if ref.get("attachment_id"):
|
||||||
|
parts.append(f"id={ref['attachment_id']}")
|
||||||
|
if ref.get("mime"):
|
||||||
|
parts.append(f"mime={ref['mime']}")
|
||||||
|
if ref.get("size"):
|
||||||
|
parts.append(f"size={ref['size']} bytes")
|
||||||
|
if ref.get("checksum_sha256"):
|
||||||
|
parts.append(f"sha256={ref['checksum_sha256']}")
|
||||||
|
line = "[" + " | ".join(parts) + "]"
|
||||||
|
if ref.get("vision"):
|
||||||
|
line += f"\n[Attachment description: {str(ref['vision']).strip()}]"
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def _text_from_blocks(blocks: Iterable[Any]) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
omitted_media = 0
|
||||||
|
for block in blocks:
|
||||||
|
if isinstance(block, str):
|
||||||
|
lines.append(strip_inline_data_urls(block))
|
||||||
|
continue
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
block_type = block.get("type")
|
||||||
|
if block_type == "text":
|
||||||
|
text = block.get("text")
|
||||||
|
if isinstance(text, str) and text:
|
||||||
|
lines.append(strip_inline_data_urls(text))
|
||||||
|
elif block_type == "attachment_ref":
|
||||||
|
lines.append(_ref_line(block))
|
||||||
|
elif block_type in MEDIA_BLOCK_TYPES:
|
||||||
|
omitted_media += 1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
encoded = json.dumps(block, ensure_ascii=True, sort_keys=True)
|
||||||
|
except TypeError:
|
||||||
|
encoded = str(block)
|
||||||
|
lines.append(strip_inline_data_urls(encoded))
|
||||||
|
if omitted_media:
|
||||||
|
plural = "s" if omitted_media != 1 else ""
|
||||||
|
lines.append(f"[{omitted_media} inline media payload{plural} omitted]")
|
||||||
|
return "\n".join(line for line in lines if line).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def persistable_message_content(
|
||||||
|
content: Any,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return content safe for DB persistence and FTS indexing.
|
||||||
|
|
||||||
|
Multimodal provider blocks are collapsed to readable text plus stable
|
||||||
|
attachment reference lines from metadata. This avoids storing base64 media
|
||||||
|
in ``chat_messages.content`` while preserving enough context for reloads,
|
||||||
|
search, and later turns.
|
||||||
|
"""
|
||||||
|
if isinstance(content, list):
|
||||||
|
text = _text_from_blocks(content)
|
||||||
|
refs = attachment_refs_from_metadata(metadata)
|
||||||
|
ref_lines = [_ref_line(ref) for ref in refs]
|
||||||
|
if ref_lines:
|
||||||
|
text = "\n".join([part for part in (text, "\n".join(ref_lines)) if part]).strip()
|
||||||
|
return text
|
||||||
|
if isinstance(content, str):
|
||||||
|
return strip_inline_data_urls(content)
|
||||||
|
try:
|
||||||
|
return strip_inline_data_urls(json.dumps(content, ensure_ascii=True, sort_keys=True))
|
||||||
|
except TypeError:
|
||||||
|
return strip_inline_data_urls(str(content))
|
||||||
|
|
||||||
|
|
||||||
|
def search_index_text(content: Any) -> str:
|
||||||
|
"""Best-effort searchable text for legacy stored content."""
|
||||||
|
if isinstance(content, str):
|
||||||
|
raw = content.strip()
|
||||||
|
if raw.startswith("[") and '"type"' in raw:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(content)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed = None
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return _text_from_blocks(parsed)
|
||||||
|
return strip_inline_data_urls(content)
|
||||||
|
if isinstance(content, list):
|
||||||
|
return _text_from_blocks(content)
|
||||||
|
return persistable_message_content(content)
|
||||||
+615
-101
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