mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-07 21:52:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40880f8fdb | ||
|
|
68aa830485 | ||
|
|
8257c661ad | ||
|
|
84c7eabfce | ||
|
|
fd79776964 | ||
|
|
03d25943ee | ||
|
|
71e3649985 | ||
|
|
45a57c0c38 | ||
|
|
71f6dc4e5d | ||
|
|
0c8a06a937 | ||
|
|
eea39ce103 | ||
|
|
9a5a15e17f | ||
|
|
f141ad2be5 | ||
|
|
a6e9294394 | ||
|
|
cfbe4aeca4 | ||
|
|
6f0679bb25 | ||
|
|
93c93bf076 | ||
|
|
f0f7464dfa | ||
|
|
1e57f774d3 | ||
|
|
2b310010cf | ||
|
|
b88154ecf9 | ||
|
|
e7796f71d3 | ||
|
|
412789051e | ||
|
|
b8adc4eb60 | ||
|
|
2246215b79 | ||
|
|
06fee0a3d8 | ||
|
|
f3813c261c | ||
|
|
e7a376b161 | ||
|
|
fec2bb11d6 | ||
|
|
c811f76d44 | ||
|
|
02d28c06d0 | ||
|
|
00ecf1c74e | ||
|
|
63feb227bb | ||
|
|
12fcddefab | ||
|
|
b46aa02ce1 | ||
|
|
352ee706f3 | ||
|
|
98b359cd4c | ||
|
|
2a2256af0e | ||
|
|
4f131d7c21 | ||
|
|
945e747eb4 | ||
|
|
61051d28f0 | ||
|
|
d87ae2a7f5 | ||
|
|
0e49c4ef40 | ||
|
|
1aff71836b | ||
|
|
b1c3125865 | ||
|
|
dbee1155e6 | ||
|
|
56c7cdf804 | ||
|
|
fe5d5caf34 | ||
|
|
bb654482f3 | ||
|
|
f45724faed | ||
|
|
6fc6095aec | ||
|
|
ef16398c5c | ||
|
|
6037707430 | ||
|
|
5c542d03ac | ||
|
|
4fe8fb28e5 | ||
|
|
84e232e7ee | ||
|
|
2cace27723 | ||
|
|
508c7b3d9d | ||
|
|
5b8a72931d | ||
|
|
2e7edf908d | ||
|
|
b80f9afb3e | ||
|
|
533d48ad8d | ||
|
|
d5bc7fb70e | ||
|
|
3b8c60ac1e | ||
|
|
d237c3fbba | ||
|
|
f510b1973a | ||
|
|
0eb6b9eb1d | ||
|
|
fe81c491cd | ||
|
|
1339efa856 | ||
|
|
6f5c28b345 | ||
|
|
aa2db8d87c | ||
|
|
8b8fc62449 | ||
|
|
0f996f58da | ||
|
|
18329268b0 | ||
|
|
6cc98f34b8 | ||
|
|
c493a66101 | ||
|
|
d5949c0286 | ||
|
|
9ec897d947 | ||
|
|
9257f0e6d0 | ||
|
|
a334a21d57 | ||
|
|
87ef52f7cc | ||
|
|
25617a8c10 | ||
|
|
4992cc620f | ||
|
|
59cca6b915 | ||
|
|
1ddda10716 | ||
|
|
958848f9f8 | ||
|
|
571ac09cea | ||
|
|
f979e5602f | ||
|
|
0f95032177 | ||
|
|
b3e2e628ab | ||
|
|
f1a45e85c5 | ||
|
|
f843a3dc01 | ||
|
|
72ba37af6b | ||
|
|
5bf4524618 | ||
|
|
1a7c79946b | ||
|
|
6d102a213f | ||
|
|
de8f151632 | ||
|
|
cce5892eeb | ||
|
|
f4e3ae6e68 | ||
|
|
671cbd79ac | ||
|
|
2e1c2a343a | ||
|
|
0116f0a62b | ||
|
|
c237a1cb9c | ||
|
|
6b284d2bc5 | ||
|
|
8acaec20ee | ||
|
|
5f18bcf6f8 | ||
|
|
18b6140511 | ||
|
|
eeb1107f7b | ||
|
|
4795c8d462 | ||
|
|
5c738a5a8e | ||
|
|
0ff5d47a8c | ||
|
|
f780f1dce9 | ||
|
|
57a1859f08 | ||
|
|
1c5853003e | ||
|
|
0f8b137713 | ||
|
|
2c5d5005ba | ||
|
|
901b3ff42d | ||
|
|
c207ed1ed8 | ||
|
|
8b3a866899 | ||
|
|
a646b59110 | ||
|
|
4079f12ae6 | ||
|
|
4d8d363681 | ||
|
|
a7e8a6ef2e | ||
|
|
12631da4d3 | ||
|
|
927985f847 | ||
|
|
2046ce893f | ||
|
|
35ef91b25c | ||
|
|
091364f7e6 | ||
|
|
91922d854c | ||
|
|
0133de6f0e | ||
|
|
641752387b | ||
|
|
339c6886cb | ||
|
|
25c2fad3e1 | ||
|
|
d105fbde95 | ||
|
|
b3f86a0724 | ||
|
|
94dc74e500 | ||
|
|
fd27e9a429 | ||
|
|
76abce349b | ||
|
|
4954cc2e8c | ||
|
|
bf3c000d1c | ||
|
|
7fd4e0e9bb | ||
|
|
8cd96aad46 | ||
|
|
6123b70b5c | ||
|
|
bfa514f6e8 | ||
|
|
1ff2fd0054 | ||
|
|
ad3e309074 | ||
|
|
15c4cef36a | ||
|
|
84cdd866c5 | ||
|
|
50b05bf54f | ||
|
|
dbfc96b8f3 | ||
|
|
a7c03f85bf |
@@ -0,0 +1,17 @@
|
|||||||
|
"""Reject skipped/empty native CI runs without conflating driver child counts."""
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('report', type=Path)
|
||||||
|
parser.add_argument('--minimum', type=int, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
root = ET.parse(args.report).getroot()
|
||||||
|
cases = list(root.iter('testcase'))
|
||||||
|
if len(cases) < args.minimum:
|
||||||
|
raise SystemExit(f'Incomplete execution: {len(cases)} cases, expected at least {args.minimum}')
|
||||||
|
for case in cases:
|
||||||
|
if any(case.find(tag) is not None for tag in ('skipped', 'failure', 'error')):
|
||||||
|
raise SystemExit(f'Case did not pass: {case.get("classname")}::{case.get("name")}')
|
||||||
|
print(f'Verified {len(cases)} executed cases; no skipped, failed or errored cases')
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { parse } from 'acorn';
|
||||||
|
|
||||||
|
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'tests/native-ci-manifest.json'), 'utf8'));
|
||||||
|
function files(directory) {
|
||||||
|
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
||||||
|
const target = path.join(directory, entry.name);
|
||||||
|
return entry.isDirectory() ? files(target) : /\.(?:mjs|cjs|js)$/.test(entry.name) ? [target] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function importsNativeTest(node) {
|
||||||
|
if (!node || typeof node !== 'object') return false;
|
||||||
|
if (node.type === 'ImportDeclaration' && node.source.value === 'node:test') return true;
|
||||||
|
if (node.type === 'CallExpression' && node.callee.name === 'require' && node.arguments[0]?.value === 'node:test') return true;
|
||||||
|
return Object.values(node).some(value => Array.isArray(value) ? value.some(importsNativeTest) : importsNativeTest(value));
|
||||||
|
}
|
||||||
|
const discovered = files(path.join(root, 'tests')).filter(file => {
|
||||||
|
const source = fs.readFileSync(file, 'utf8');
|
||||||
|
return source.includes('node:test') && importsNativeTest(parse(source, { ecmaVersion: 'latest', sourceType: 'module' }));
|
||||||
|
}).map(file => path.relative(root, file).split(path.sep).join('/')).sort();
|
||||||
|
const assigned = Object.values(manifest).flatMap(group => Array.isArray(group) ? group : Object.keys(group));
|
||||||
|
if (new Set(assigned).size !== assigned.length || JSON.stringify([...assigned].sort()) !== JSON.stringify(discovered)) {
|
||||||
|
throw new Error('Native test coverage must be exhaustive and disjoint; update tests/native-ci-manifest.json for added, removed or duplicated files');
|
||||||
|
}
|
||||||
|
for (const wrappers of Object.values(manifest['python-ci'])) {
|
||||||
|
for (const wrapper of wrappers) if (!fs.existsSync(path.join(root, wrapper))) throw new Error(`Missing pytest wrapper: ${wrapper}`);
|
||||||
|
}
|
||||||
|
if (process.argv.includes('--list')) {
|
||||||
|
console.log(JSON.stringify({ discovered, runners: manifest }, null, 2));
|
||||||
|
} else {
|
||||||
|
const reports = path.join(root, 'test-results/native-node');
|
||||||
|
fs.mkdirSync(reports, { recursive: true });
|
||||||
|
const result = spawnSync(process.execPath, ['--test', '--test-concurrency=1',
|
||||||
|
'--test-reporter=spec', '--test-reporter-destination=stdout',
|
||||||
|
'--test-reporter=junit', `--test-reporter-destination=${path.join(reports, 'junit.xml')}`,
|
||||||
|
...manifest['native-ci']], {
|
||||||
|
cwd: root, stdio: 'inherit',
|
||||||
|
env: { ...process.env, ENDPOINT_URL: 'http://127.0.0.1:1' },
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
process.exitCode = result.status ?? 1;
|
||||||
|
}
|
||||||
+465
@@ -0,0 +1,465 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail-closed independent verification and sanitization gate for test diagnostics.
|
||||||
|
|
||||||
|
Runs after test suite completion or abnormal runner termination (e.g. failures, timeouts, crashes).
|
||||||
|
Ensures that no unsanitized traces, raw credentials, temporary archives, or unauthorized
|
||||||
|
diagnostics can ever be published as CI artifacts, while preserving safe JUnit XML reporting.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add repository root to Python path to import sanitizer
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scripts.sanitize_trace_artifacts import sanitize_trace_archive
|
||||||
|
except ImportError:
|
||||||
|
# If import fails, define fail-closed fallback
|
||||||
|
def sanitize_trace_archive(zp: str) -> bool:
|
||||||
|
p = Path(zp)
|
||||||
|
if p.exists():
|
||||||
|
try:
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||||
|
logger = logging.getLogger("verify_sanitized_artifacts")
|
||||||
|
|
||||||
|
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
ALLOWED_JUNIT_ROOT_TAGS = frozenset({"testsuites", "testsuite"})
|
||||||
|
ALLOWED_PUBLISHED_JUNIT_TAGS = frozenset({"testsuites", "testsuite", "testcase", "failure", "error", "skipped"})
|
||||||
|
DISALLOWED_JUNIT_TAGS = frozenset({"properties", "property", "system-out", "system-err"})
|
||||||
|
|
||||||
|
ALLOWED_ATTRIBUTES_BY_TAG = {
|
||||||
|
"testsuites": frozenset({"name", "tests", "failures", "errors", "skipped", "time", "id", "disabled"}),
|
||||||
|
"testsuite": frozenset({
|
||||||
|
"name", "tests", "failures", "errors", "skipped", "time", "timestamp", "hostname",
|
||||||
|
"id", "package", "disabled", "file"
|
||||||
|
}),
|
||||||
|
"testcase": frozenset({"name", "classname", "time", "file", "line", "assertions", "status"}),
|
||||||
|
"failure": frozenset({"message", "type"}),
|
||||||
|
"error": frozenset({"message", "type"}),
|
||||||
|
"skipped": frozenset({"message", "type"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
SAFE_SKIPPED_STATUSES = frozenset({"skipped", "todo", "ignored", "disabled", "pending", "omitted", "test skipped"})
|
||||||
|
|
||||||
|
SENSITIVE_KEY_PATTERN = re.compile(
|
||||||
|
r"(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|"
|
||||||
|
r"private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)"
|
||||||
|
)
|
||||||
|
|
||||||
|
SECRET_REDACTION_PATTERNS = [
|
||||||
|
# 1. Authorization headers (Bearer / Basic)
|
||||||
|
re.compile(r"(?i)\b(bearer|basic)\s+([^\s,;\'\"<>]+)"),
|
||||||
|
# 2. Sensitive key-value pairs (key=value or key: value)
|
||||||
|
re.compile(
|
||||||
|
r"(?i)\b([a-z0-9_.-]*(?:password|passwd|secret|token|api_key|apikey|access_token|refresh_token|"
|
||||||
|
r"auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)[a-z0-9_.-]*)"
|
||||||
|
r"\s*([:=])\s*([^\s,;\'\"<>]+)"
|
||||||
|
),
|
||||||
|
# 3. URL credentials (scheme://user:pass@host)
|
||||||
|
re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://[^:\s]+):([^@\s]+)@"),
|
||||||
|
# 4. JSON-formatted key-value pairs
|
||||||
|
re.compile(
|
||||||
|
r'(?i)("?(?:[a-z0-9_.-]*(?:password|passwd|secret|token|key|auth)[a-z0-9_.-]*)"?\s*:\s*")([^"]+)(")'
|
||||||
|
),
|
||||||
|
# 5. Synthetic secret keywords or tokens
|
||||||
|
re.compile(r"(?i)\b[a-z0-9_.-]*synthetic[-_]?[a-z0-9_.-]*\b"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def safe_remove(filepath: str | Path) -> bool:
|
||||||
|
"""Safely remove a file or directory fail-closed.
|
||||||
|
|
||||||
|
Returns True if the target does not exist or was successfully removed.
|
||||||
|
Returns False if removal failed and the target still exists on disk.
|
||||||
|
"""
|
||||||
|
p = Path(filepath)
|
||||||
|
if not p.exists():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
if p.is_dir():
|
||||||
|
shutil.rmtree(p, ignore_errors=False)
|
||||||
|
else:
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("Failed to delete unauthorized file %s: %s", filepath, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if p.exists():
|
||||||
|
logger.error("Target %s still exists on disk after deletion attempt", filepath)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_text_content(text: str) -> tuple[str, bool]:
|
||||||
|
"""Scrub sensitive credentials, tokens, and synthetic secrets from text."""
|
||||||
|
if not text or not isinstance(text, str):
|
||||||
|
return text, False
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
out = text
|
||||||
|
|
||||||
|
# 1. Replace Bearer / Basic tokens
|
||||||
|
def _sub_auth(m: re.Match) -> str:
|
||||||
|
nonlocal modified
|
||||||
|
modified = True
|
||||||
|
return f"{m.group(1)} [REDACTED]"
|
||||||
|
|
||||||
|
out = SECRET_REDACTION_PATTERNS[0].sub(_sub_auth, out)
|
||||||
|
|
||||||
|
# 2. Replace key-value pairs
|
||||||
|
def _sub_kv(m: re.Match) -> str:
|
||||||
|
nonlocal modified
|
||||||
|
modified = True
|
||||||
|
return f"{m.group(1)}{m.group(2)}[REDACTED]"
|
||||||
|
|
||||||
|
out = SECRET_REDACTION_PATTERNS[1].sub(_sub_kv, out)
|
||||||
|
|
||||||
|
# 3. Replace URL credentials
|
||||||
|
def _sub_url(m: re.Match) -> str:
|
||||||
|
nonlocal modified
|
||||||
|
modified = True
|
||||||
|
return f"{m.group(1)}:[REDACTED]@"
|
||||||
|
|
||||||
|
out = SECRET_REDACTION_PATTERNS[2].sub(_sub_url, out)
|
||||||
|
|
||||||
|
# 4. Replace JSON key-value secrets
|
||||||
|
def _sub_json(m: re.Match) -> str:
|
||||||
|
nonlocal modified
|
||||||
|
modified = True
|
||||||
|
return f"{m.group(1)}[REDACTED]{m.group(3)}"
|
||||||
|
|
||||||
|
out = SECRET_REDACTION_PATTERNS[3].sub(_sub_json, out)
|
||||||
|
|
||||||
|
# 5. Replace synthetic secret markers
|
||||||
|
def _sub_synthetic(m: re.Match) -> str:
|
||||||
|
nonlocal modified
|
||||||
|
modified = True
|
||||||
|
return "[REDACTED]"
|
||||||
|
|
||||||
|
out = SECRET_REDACTION_PATTERNS[4].sub(_sub_synthetic, out)
|
||||||
|
|
||||||
|
return out, modified
|
||||||
|
|
||||||
|
|
||||||
|
def prune_and_sanitize_junit_element(elem: ET.Element) -> bool:
|
||||||
|
"""Recursively enforce restrictive published-JUnit representation.
|
||||||
|
|
||||||
|
Removes properties, property entries, system-out, system-err, and any
|
||||||
|
unnecessary free-form content. Preserves test identities, counts, outcomes,
|
||||||
|
and safe failure classifications.
|
||||||
|
Returns True if any modifications were made.
|
||||||
|
"""
|
||||||
|
modified = False
|
||||||
|
|
||||||
|
# 1. Prune child elements not permitted in restrictive published representation
|
||||||
|
for child in list(elem):
|
||||||
|
tag_lower = child.tag.lower()
|
||||||
|
if tag_lower in DISALLOWED_JUNIT_TAGS or tag_lower not in ALLOWED_PUBLISHED_JUNIT_TAGS:
|
||||||
|
elem.remove(child)
|
||||||
|
modified = True
|
||||||
|
else:
|
||||||
|
if prune_and_sanitize_junit_element(child):
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
tag_lower = elem.tag.lower()
|
||||||
|
|
||||||
|
# 2. Prune attributes not permitted in restrictive published representation
|
||||||
|
allowed_attrs = ALLOWED_ATTRIBUTES_BY_TAG.get(tag_lower, frozenset())
|
||||||
|
for attr_name in list(elem.attrib.keys()):
|
||||||
|
attr_lower = attr_name.lower()
|
||||||
|
if attr_lower not in allowed_attrs:
|
||||||
|
del elem.attrib[attr_name]
|
||||||
|
modified = True
|
||||||
|
elif tag_lower == "skipped":
|
||||||
|
val = elem.attrib[attr_name].strip()
|
||||||
|
if val.lower() in SAFE_SKIPPED_STATUSES:
|
||||||
|
if elem.attrib[attr_name] != val.lower():
|
||||||
|
elem.attrib[attr_name] = val.lower()
|
||||||
|
modified = True
|
||||||
|
else:
|
||||||
|
elem.attrib[attr_name] = "skipped"
|
||||||
|
modified = True
|
||||||
|
else:
|
||||||
|
val = elem.attrib[attr_name]
|
||||||
|
if isinstance(val, str):
|
||||||
|
new_val, changed = sanitize_text_content(val)
|
||||||
|
if changed:
|
||||||
|
elem.attrib[attr_name] = new_val
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
# 3. Handle text content: structural and skipped elements do not carry free-form text
|
||||||
|
if tag_lower in ("testsuites", "testsuite", "testcase", "skipped"):
|
||||||
|
if elem.text:
|
||||||
|
elem.text = None
|
||||||
|
modified = True
|
||||||
|
elif tag_lower in ("failure", "error"):
|
||||||
|
if elem.text:
|
||||||
|
new_text, changed = sanitize_text_content(elem.text)
|
||||||
|
if changed:
|
||||||
|
elem.text = new_text
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
# 4. Clean up tails: strip any non-whitespace characters from tails to prevent leaks
|
||||||
|
if elem.tail and elem.tail.strip():
|
||||||
|
cleaned_tail = re.sub(r"\S+", "", elem.tail)
|
||||||
|
if cleaned_tail != elem.tail:
|
||||||
|
elem.tail = cleaned_tail
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
return modified
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_and_verify_junit_xml(filepath: str | Path) -> bool:
|
||||||
|
"""Verify that an XML file is an authentic JUnit report and enforce published representation.
|
||||||
|
|
||||||
|
Returns True if the XML file is a valid recognized JUnit report and was safely sanitized.
|
||||||
|
Returns False if the file is invalid, unrecognized, corrupted, or failed sanitization.
|
||||||
|
"""
|
||||||
|
p = Path(filepath)
|
||||||
|
name_lower = p.name.lower()
|
||||||
|
|
||||||
|
# 1. Filename restriction: must match standard JUnit report naming
|
||||||
|
if not (name_lower == "junit.xml" or name_lower == "browser-junit.xml" or name_lower.endswith("-junit.xml") or name_lower.endswith(".junit.xml")):
|
||||||
|
logger.warning("Unrecognized XML report filename %s; not an authorized JUnit report", filepath)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Parse XML
|
||||||
|
try:
|
||||||
|
tree = ET.parse(p)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Corrupted XML report %s: %s", filepath, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
root = tree.getroot()
|
||||||
|
# 3. Root tag restriction
|
||||||
|
if root.tag.lower() not in ALLOWED_JUNIT_ROOT_TAGS:
|
||||||
|
logger.warning("XML report %s has invalid root element <%s>; not a JUnit report", filepath, root.tag)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 4. Enforce restrictive published representation on root and all descendants
|
||||||
|
prune_and_sanitize_junit_element(root)
|
||||||
|
|
||||||
|
# 5. Normalize and rewrite sanitized report
|
||||||
|
try:
|
||||||
|
tree.write(str(p), encoding="utf-8", xml_declaration=True)
|
||||||
|
# Verify rewritten XML parses cleanly
|
||||||
|
ET.parse(p)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to write sanitized XML report %s: %s", filepath, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_disposable_ci_fixture(target_dir: str | Path | None = None) -> bool:
|
||||||
|
"""Verify that execution ran inside an authorized disposable synthetic fixture."""
|
||||||
|
is_ci = bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"))
|
||||||
|
if not is_ci:
|
||||||
|
# Local runs require explicit trace opt-in
|
||||||
|
trace_opt_in = os.environ.get("PLAYWRIGHT_ENABLE_TRACE", "").lower() in ("1", "true")
|
||||||
|
return trace_opt_in
|
||||||
|
|
||||||
|
if os.environ.get("ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS") != "1":
|
||||||
|
return False
|
||||||
|
if os.environ.get("PHOTO_EDITOR_E2E_AUTH") == "0":
|
||||||
|
return False
|
||||||
|
|
||||||
|
allowed_tmp = [os.path.abspath(p) for p in (tempfile.gettempdir(), "/tmp", "/var/tmp")]
|
||||||
|
|
||||||
|
# Check 1: Verify fixture ownership via PHOTO_EDITOR_E2E_DATA_DIR
|
||||||
|
data_dir = os.environ.get("PHOTO_EDITOR_E2E_DATA_DIR")
|
||||||
|
if data_dir:
|
||||||
|
resolved = os.path.abspath(data_dir)
|
||||||
|
base_name = os.path.basename(resolved)
|
||||||
|
is_tmp = any(resolved.startswith(b + os.sep) or resolved == b for b in allowed_tmp)
|
||||||
|
if base_name.startswith("odysseus-photo-editor-e2e-") and is_tmp:
|
||||||
|
expected_db = os.path.join(resolved, "app.db")
|
||||||
|
db_path = os.environ.get("PHOTO_EDITOR_E2E_DB_PATH")
|
||||||
|
if not db_path or db_path == expected_db:
|
||||||
|
marker_path = os.path.join(resolved, ".disposable-ci-fixture.json")
|
||||||
|
if os.path.exists(marker_path):
|
||||||
|
try:
|
||||||
|
with open(marker_path, "r", encoding="utf-8") as f:
|
||||||
|
fixture = json.load(f)
|
||||||
|
if (
|
||||||
|
fixture.get("synthetic") is True
|
||||||
|
and fixture.get("authenticated") is True
|
||||||
|
and fixture.get("database") == expected_db
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check 2: Verify fixture proof in target_dir if provided
|
||||||
|
if target_dir:
|
||||||
|
target_marker = os.path.join(str(target_dir), ".disposable-ci-fixture.json")
|
||||||
|
if os.path.exists(target_marker):
|
||||||
|
try:
|
||||||
|
with open(target_marker, "r", encoding="utf-8") as f:
|
||||||
|
fixture = json.load(f)
|
||||||
|
db_in_fixture = fixture.get("database", "")
|
||||||
|
resolved_db = os.path.abspath(db_in_fixture)
|
||||||
|
db_dir = os.path.dirname(resolved_db)
|
||||||
|
db_base = os.path.basename(db_dir)
|
||||||
|
is_tmp = any(db_dir.startswith(b + os.sep) or db_dir == b for b in allowed_tmp)
|
||||||
|
if (
|
||||||
|
fixture.get("synthetic") is True
|
||||||
|
and fixture.get("authenticated") is True
|
||||||
|
and db_base.startswith("odysseus-photo-editor-e2e-")
|
||||||
|
and is_tmp
|
||||||
|
and os.path.basename(resolved_db) == "app.db"
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_and_sanitize_artifacts(target_dir: str) -> bool:
|
||||||
|
"""Independently verify and sanitize diagnostics fail-closed, preserving safe JUnit XML.
|
||||||
|
|
||||||
|
Returns True if verification succeeded and artifacts are safe for publication.
|
||||||
|
Returns False if verification failed or deletion could not be confirmed.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(target_dir):
|
||||||
|
logger.info("Target directory %s does not exist; nothing to sanitize.", target_dir)
|
||||||
|
return True
|
||||||
|
|
||||||
|
disposable_ci = is_valid_disposable_ci_fixture(target_dir)
|
||||||
|
preserved_xmls = 0
|
||||||
|
sanitized_archives = 0
|
||||||
|
purged_files = 0
|
||||||
|
overall_success = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(target_dir, topdown=False):
|
||||||
|
for filename in files:
|
||||||
|
filepath = os.path.join(root, filename)
|
||||||
|
name_lower = filename.lower()
|
||||||
|
|
||||||
|
# Cleanup disposable fixture marker so it is not published
|
||||||
|
if filename == ".disposable-ci-fixture.json":
|
||||||
|
if not safe_remove(filepath):
|
||||||
|
overall_success = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 1. XML reports: verify JUnit structure and sanitize credentials
|
||||||
|
if name_lower.endswith(".xml"):
|
||||||
|
if sanitize_and_verify_junit_xml(filepath):
|
||||||
|
preserved_xmls += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.warning("Rejecting invalid/unrecognized XML report %s; purging fail-closed", filepath)
|
||||||
|
if not safe_remove(filepath):
|
||||||
|
overall_success = False
|
||||||
|
purged_files += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Check if diagnostics are authorized (disposable synthetic fixture)
|
||||||
|
if not disposable_ci:
|
||||||
|
logger.info("Purging diagnostic artifact %s (disposable CI fixture not verified)", filepath)
|
||||||
|
if not safe_remove(filepath):
|
||||||
|
overall_success = False
|
||||||
|
purged_files += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3. Disposable CI is authorized: enforce strict sanitization
|
||||||
|
if name_lower.endswith(".zip"):
|
||||||
|
ok = sanitize_trace_archive(filepath)
|
||||||
|
if ok and os.path.exists(filepath):
|
||||||
|
sanitized_archives += 1
|
||||||
|
else:
|
||||||
|
logger.warning("Trace archive %s failed sanitization; purging fail-closed", filepath)
|
||||||
|
if not safe_remove(filepath):
|
||||||
|
overall_success = False
|
||||||
|
purged_files += 1
|
||||||
|
|
||||||
|
elif name_lower.endswith(".png"):
|
||||||
|
# Validate PNG magic bytes to ensure not a secret payload disguised as an image
|
||||||
|
is_png = False
|
||||||
|
try:
|
||||||
|
with open(filepath, "rb") as pf:
|
||||||
|
header = pf.read(8)
|
||||||
|
if header == PNG_MAGIC:
|
||||||
|
is_png = True
|
||||||
|
except Exception:
|
||||||
|
is_png = False
|
||||||
|
|
||||||
|
if is_png:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.warning("Invalid or suspicious screenshot %s; purging fail-closed", filepath)
|
||||||
|
if not safe_remove(filepath):
|
||||||
|
overall_success = False
|
||||||
|
purged_files += 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Purge any .tmp, .har, .db, .sqlite, .log or unrecognized files
|
||||||
|
logger.info("Purging unauthorized diagnostic file %s", filepath)
|
||||||
|
if not safe_remove(filepath):
|
||||||
|
overall_success = False
|
||||||
|
purged_files += 1
|
||||||
|
|
||||||
|
# Remove empty subdirectories
|
||||||
|
for d in dirs:
|
||||||
|
dirpath = os.path.join(root, d)
|
||||||
|
try:
|
||||||
|
if not os.listdir(dirpath):
|
||||||
|
os.rmdir(dirpath)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not overall_success:
|
||||||
|
logger.error("Verification failed: one or more unauthorized files could not be purged.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Artifact verification complete for %s: preserved %d XML reports, sanitized %d archives, purged %d files.",
|
||||||
|
target_dir,
|
||||||
|
preserved_xmls,
|
||||||
|
sanitized_archives,
|
||||||
|
purged_files,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Unexpected error during artifact verification: %s; purging all non-XML files fail-closed", exc)
|
||||||
|
try:
|
||||||
|
for p in Path(target_dir).rglob("*"):
|
||||||
|
if p.is_file() and not p.name.lower().endswith(".xml"):
|
||||||
|
safe_remove(p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("target_dir", nargs="?", default="test-results", help="Directory containing test results")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
ok = verify_and_sanitize_artifacts(args.target_dir)
|
||||||
|
sys.exit(0 if ok else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -256,3 +256,10 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PYTEST_SHARD: ${{ matrix.shard }}
|
PYTEST_SHARD: ${{ matrix.shard }}
|
||||||
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
||||||
|
|
||||||
|
- name: Playwright mobile layout checks (shard 1/4 only)
|
||||||
|
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
|
||||||
|
run: |
|
||||||
|
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
|
||||||
|
--config tests/e2e/ios-layout.config.cjs \
|
||||||
|
--reporter=line
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
name: Native test suites
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: native-tests-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
native-node:
|
||||||
|
name: Native Node regressions
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
with:
|
||||||
|
node-version: '22.23.4'
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||||
|
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
- name: Validate exhaustive disjoint runner mapping and execute omitted suites
|
||||||
|
run: |
|
||||||
|
node .github/scripts/run_native_tests.mjs
|
||||||
|
python .github/scripts/check_test_execution.py test-results/native-node/junit.xml --minimum 43
|
||||||
|
- name: Verify and sanitize native test results fail-closed
|
||||||
|
id: verify-native-results
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
python .github/scripts/verify_sanitized_artifacts.py test-results/native-node/
|
||||||
|
- name: Save native test identities and results
|
||||||
|
if: always() && steps.verify-native-results.outcome == 'success'
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: native-node-results
|
||||||
|
path: test-results/native-node/
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
browser:
|
||||||
|
name: Chromium application regressions
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS: '1'
|
||||||
|
ODYSSEUS_TEST_PYTHON: python
|
||||||
|
CHROMADB_HOST: 127.0.0.1
|
||||||
|
CHROMADB_PORT: '1'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Install FFmpeg and establish system trust
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends ffmpeg ca-certificates
|
||||||
|
python scripts/check_ca_trust.py --system
|
||||||
|
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||||
|
with:
|
||||||
|
version: '0.13.5'
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: requirements.txt
|
||||||
|
prune-cache: false
|
||||||
|
- run: |
|
||||||
|
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||||
|
python scripts/check_ca_trust.py --python
|
||||||
|
uv pip install --system -r requirements.txt
|
||||||
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
with:
|
||||||
|
node-version: '22.23.4'
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||||
|
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
- name: Run complete Chromium suite against a fresh authenticated app
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_JUNIT_OUTPUT_FILE: test-results/browser-junit.xml
|
||||||
|
run: |
|
||||||
|
npm run test:photo-editor -- --reporter=line,junit
|
||||||
|
python .github/scripts/check_test_execution.py test-results/browser-junit.xml --minimum 137
|
||||||
|
- name: Verify and sanitize failure diagnostics fail-closed
|
||||||
|
id: verify-browser-results
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
python .github/scripts/verify_sanitized_artifacts.py test-results/
|
||||||
|
- name: Save per-test results and sanitized failure diagnostics
|
||||||
|
if: always() && steps.verify-browser-results.outcome == 'success'
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: chromium-regression-results
|
||||||
|
path: test-results/
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
authoritative-native-suites:
|
||||||
|
name: Authoritative native suites
|
||||||
|
if: always()
|
||||||
|
needs: [native-node, browser]
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Require both complete suites to succeed
|
||||||
|
env:
|
||||||
|
NODE_RESULT: ${{ needs.native-node.result }}
|
||||||
|
BROWSER_RESULT: ${{ needs.browser.result }}
|
||||||
|
run: |
|
||||||
|
test "$NODE_RESULT" = success
|
||||||
|
test "$BROWSER_RESULT" = success
|
||||||
@@ -53,6 +53,8 @@ services/data/
|
|||||||
|
|
||||||
# Test capture artifacts (browser session dumps may contain personal content)
|
# Test capture artifacts (browser session dumps may contain personal content)
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -35,3 +35,15 @@ regexTarget = "secret"
|
|||||||
regexes = [
|
regexes = [
|
||||||
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
|
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[allowlists]]
|
||||||
|
description = "Synthetic dummy API key in trace artifact sanitizer test fixture"
|
||||||
|
targetRules = ["generic-api-key"]
|
||||||
|
condition = "AND"
|
||||||
|
paths = [
|
||||||
|
'''(?:^|/)tests/test_trace_artifact_sanitizer\.py$''',
|
||||||
|
]
|
||||||
|
regexTarget = "secret"
|
||||||
|
regexes = [
|
||||||
|
'''^sk-123456789$''',
|
||||||
|
]
|
||||||
|
|||||||
+18
-3
@@ -1,9 +1,22 @@
|
|||||||
|
# Both stages need the fixed native decoder, including curl in the builder and
|
||||||
|
# distro Node in the runtime. A cached Python base may predate the Debian fix.
|
||||||
|
FROM python:3.14.9-slim AS brotli-base
|
||||||
|
COPY scripts/check_brotli.py /usr/local/bin/check-brotli.py
|
||||||
|
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
||||||
|
# Check inherited trust before the first HTTPS package request, retaining Debian's
|
||||||
|
# signed archive verification. Refresh only the explicit native dependency.
|
||||||
|
RUN python /usr/local/bin/check-ca-trust.py --system \
|
||||||
|
&& sed -i 's|http://deb.debian.org/|https://deb.debian.org/|g' /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends libbrotli1 \
|
||||||
|
&& python /usr/local/bin/check-brotli.py \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
||||||
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
||||||
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
|
# 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
|
# the final image / Cookbook never has to compile the broken sdists. See
|
||||||
# docker/build-realesrgan-wheels.sh for the full rationale.
|
# docker/build-realesrgan-wheels.sh for the full rationale.
|
||||||
FROM python:3.14.9-slim AS realesrgan-wheels
|
FROM brotli-base AS realesrgan-wheels
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
||||||
@@ -14,7 +27,7 @@ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --
|
|||||||
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
||||||
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
||||||
|
|
||||||
FROM python:3.14.9-slim
|
FROM brotli-base
|
||||||
|
|
||||||
# System deps. tmux is required by Cookbook for background downloads/serves.
|
# System deps. tmux is required by Cookbook for background downloads/serves.
|
||||||
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
||||||
@@ -52,6 +65,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libglib2.0-0t64 \
|
libglib2.0-0t64 \
|
||||||
libxcb1 \
|
libxcb1 \
|
||||||
libmagic1 \
|
libmagic1 \
|
||||||
|
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Refresh both Python trust bundles before installing application dependencies.
|
# Refresh both Python trust bundles before installing application dependencies.
|
||||||
@@ -132,7 +146,8 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
|
|||||||
# Check gRPC's effective defaults after all Python dependencies are installed.
|
# Check gRPC's effective defaults after all Python dependencies are installed.
|
||||||
# The application minimum fixes its bundled roots; the checked override also
|
# The application minimum fixes its bundled roots; the checked override also
|
||||||
# keeps the effective Docker selection consistent with system trust.
|
# keeps the effective Docker selection consistent with system trust.
|
||||||
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc
|
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc \
|
||||||
|
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node
|
||||||
|
|
||||||
# Copy app code
|
# Copy app code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -119,19 +119,26 @@ Enabled automatically when the editor opens (`window.__geEditorBridge`).
|
|||||||
- The page runs the op via the control API and replies:
|
- The page runs the op via the control API and replies:
|
||||||
`POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`.
|
`POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`.
|
||||||
- Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`.
|
- Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`.
|
||||||
With `wait:true` the call blocks and returns the editor's result.
|
With `wait:true` and one step the call blocks and returns the editor's result.
|
||||||
|
Otherwise use `POST /api/editor/bridge/await {id, timeout}` for each queued id.
|
||||||
|
Timeouts are seconds, greater than zero and at most 900. Pending results expire
|
||||||
|
after the command timeout; an unknown or another owner's id returns 404.
|
||||||
|
|
||||||
Round-trip example (authenticated; `<session>` = `window.__geEditorBridge.session`):
|
Round-trip example (authenticated; `<session>` = `window.__geEditorBridge.session`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \
|
curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \
|
||||||
-H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \
|
-H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \
|
||||||
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8000}'
|
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8}'
|
||||||
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
||||||
```
|
```
|
||||||
|
|
||||||
Session scoping is `owner:session`, so an agent can only drive editors owned by the
|
Session scoping uses the authenticated owner and session as separate values. A
|
||||||
same user. The bridge is a **browser-only** flow: it requires an editor page to be open.
|
bound editor-tool turn uses its server-owned session, not a model-selected one.
|
||||||
|
The bridge is a **browser-only** flow: it requires an editor page to be open.
|
||||||
|
For `/api/editor/agent`, clients must send `incognito: true` for private image
|
||||||
|
results; omitted or false requests use normal gallery persistence. These
|
||||||
|
owner-scoped routes do not accept delegated API tokens without a scope contract.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Native test merge gate
|
||||||
|
|
||||||
|
`Native test suites` runs on pull requests, dev/main pushes and manual dispatch.
|
||||||
|
`Authoritative native suites` fails unless both the native Node and complete
|
||||||
|
Chromium jobs succeed; it has no continue-on-error, retries or path bypass.
|
||||||
|
Repository administrators must require this check alongside the four pytest
|
||||||
|
checks in branch protection. The audited public dev branch had no protection or
|
||||||
|
ruleset, so merely adding workflow jobs cannot prevent a manual merge.
|
||||||
|
|
||||||
|
`tests/native-ci-manifest.json` exhaustively partitions all native Node files:
|
||||||
|
16 run in the native job,14 through existing pytest wrappers, one through the
|
||||||
|
existing frontend architecture CI step, and the formerly live email-reply file
|
||||||
|
through an explicitly identified Playwright driver. The manifest validator fails
|
||||||
|
for omitted/duplicate/unreachable entries. Node emits individual JUnit identities
|
||||||
|
and propagates its failure exit. No native file is newly run in each pytest shard.
|
||||||
|
|
||||||
|
The browser job uses the existing one-worker Chromium config and all 136 Photo
|
||||||
|
Editor cases (including cursor and brush regressions), plus one driver for the native
|
||||||
|
email-reply contract (137 cases total). Its ephemeral account,
|
||||||
|
SQLite DB and server are created by the config/setup; reuseExistingServer remains
|
||||||
|
false. NPX startup is offline/cache-only in a private cache; Notes/editor behavior
|
||||||
|
and Python MCP services remain real. No developer app or external account is used.
|
||||||
|
|
||||||
|
Default CI trace suppression remains intact. Only this job's explicit opt-in plus
|
||||||
|
a newly generated authenticated synthetic fixture marker permits failure traces.
|
||||||
|
The existing structured sanitizer runs before artifacts are uploaded; invalid
|
||||||
|
ownership, failed sanitization and missing tooling purge sensitive artifacts.
|
||||||
|
Unsupported or malformed sensitive records cause the affected archive to be removed
|
||||||
|
fail-closed. An independent fail-closed verification step (.github/scripts/verify_sanitized_artifacts.py)
|
||||||
|
runs after test suite execution or abnormal runner termination to prevent publishing
|
||||||
|
unsanitized diagnostics while preserving safe JUnit reporting. The verifier returns a nonzero
|
||||||
|
exit code upon any inspection or deletion failure, publishable XML is restricted to recognized
|
||||||
|
JUnit reports with sensitive tokens redacted, and workflow upload steps execute only when
|
||||||
|
independent verification succeeds (steps.<id>.outcome == 'success').
|
||||||
|
Never upload the fixture directory, database or browser storage state.
|
||||||
|
|
||||||
|
The stroke budget now excludes measured synthetic event-delivery pacing and also
|
||||||
|
bounds actual Chromium main-thread work at the original5s. All240 samples and
|
||||||
|
pixel/thumbnail/undo/redo assertions remain. Per-case timing attachments expose
|
||||||
|
both clocks rather than treating driver frame cadence as application work.
|
||||||
|
|
||||||
|
Three Bombadil properties remain opt-in: they require a Bombadil/browser scenario
|
||||||
|
and bounded fuzz campaign; no authoritative execution is claimed. The two model
|
||||||
|
smoke scripts require supplied model endpoints/models and remain explicit live
|
||||||
|
operations. No credentials or live-service authorization are implied by CI setup.
|
||||||
|
Firefox/WebKit alternatives remain opt-in and are not certified by Chromium CI.
|
||||||
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
|
|||||||
"prompt": {"type": "string", "description": "Image description prompt"},
|
"prompt": {"type": "string", "description": "Image description prompt"},
|
||||||
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
|
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
|
||||||
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
|
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
|
||||||
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
|
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default configured quality, otherwise medium)"},
|
||||||
},
|
},
|
||||||
"required": ["prompt"],
|
"required": ["prompt"],
|
||||||
},
|
},
|
||||||
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
|
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
model_spec = _settings.get("image_model", "")
|
model_spec = _settings.get("image_model", "")
|
||||||
if quality == "medium" and _settings.get("image_quality"):
|
if "quality" not in arguments and _settings.get("image_quality"):
|
||||||
quality = _settings["image_quality"]
|
quality = _settings["image_quality"]
|
||||||
|
|
||||||
# Auto-detect best available image model
|
# Auto-detect best available image model
|
||||||
|
|||||||
+5
-1
@@ -9,7 +9,11 @@
|
|||||||
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
|
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
|
||||||
"test:photo-editor:install": "playwright install chromium firefox webkit",
|
"test:photo-editor:install": "playwright install chromium firefox webkit",
|
||||||
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
|
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
|
||||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
|
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
|
||||||
|
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||||
|
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||||
|
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||||
|
"test:ios-layout:install": "playwright install chromium webkit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antithesishq/bombadil": "^0.7.0",
|
"@antithesishq/bombadil": "^0.7.0",
|
||||||
|
|||||||
@@ -887,6 +887,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
"""Create a new integration (admin only)."""
|
"""Create a new integration (admin only)."""
|
||||||
_require_admin_user(request)
|
_require_admin_user(request)
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Request body must be a JSON object")
|
||||||
item = add_integration(body)
|
item = add_integration(body)
|
||||||
return {"ok": True, "integration": mask_integration_secret(item)}
|
return {"ok": True, "integration": mask_integration_secret(item)}
|
||||||
|
|
||||||
@@ -895,6 +897,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
"""Update an existing integration (admin only)."""
|
"""Update an existing integration (admin only)."""
|
||||||
_require_admin_user(request)
|
_require_admin_user(request)
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Request body must be a JSON object")
|
||||||
item = update_integration(integration_id, body)
|
item = update_integration(integration_id, body)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(404, "Integration not found")
|
raise HTTPException(404, "Integration not found")
|
||||||
|
|||||||
+16
-16
@@ -80,6 +80,18 @@ def _require_user(request: Request) -> str:
|
|||||||
return FALLBACK_OWNER
|
return FALLBACK_OWNER
|
||||||
|
|
||||||
|
|
||||||
|
async def _json_object_body(request: Request) -> dict:
|
||||||
|
"""Parse a JSON object body. An unreadable body counts as empty; a body
|
||||||
|
that is valid JSON but not an object (a list, string or number) is a 400."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Request body must be a JSON object")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
|
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
|
||||||
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
|
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
|
||||||
if not cal:
|
if not cal:
|
||||||
@@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
async def save_config(request: Request):
|
async def save_config(request: Request):
|
||||||
"""Legacy single-account endpoint — upserts the first account."""
|
"""Legacy single-account endpoint — upserts the first account."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
accounts = _get_caldav_accounts(owner)
|
accounts = _get_caldav_accounts(owner)
|
||||||
if not (body.get("url") or "").strip():
|
if not (body.get("url") or "").strip():
|
||||||
_save_caldav_accounts(owner, [])
|
_save_caldav_accounts(owner, [])
|
||||||
@@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
"""Add a new CalDAV account."""
|
"""Add a new CalDAV account."""
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
from src.caldav_sync import validate_caldav_url
|
from src.caldav_sync import validate_caldav_url
|
||||||
try:
|
try:
|
||||||
url = validate_caldav_url(body.get("url", ""))
|
url = validate_caldav_url(body.get("url", ""))
|
||||||
@@ -1080,10 +1086,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
async def update_caldav_account(account_id: str, request: Request):
|
async def update_caldav_account(account_id: str, request: Request):
|
||||||
"""Update an existing CalDAV account by id."""
|
"""Update an existing CalDAV account by id."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
accounts = _get_caldav_accounts(owner)
|
accounts = _get_caldav_accounts(owner)
|
||||||
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
|
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
|
||||||
if idx is None:
|
if idx is None:
|
||||||
@@ -1124,10 +1127,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
test an already-saved account. Falls back to the first saved account
|
test an already-saved account. Falls back to the first saved account
|
||||||
when nothing is provided."""
|
when nothing is provided."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
url = (body.get("url") or "").strip()
|
url = (body.get("url") or "").strip()
|
||||||
user = (body.get("username") or "").strip()
|
user = (body.get("username") or "").strip()
|
||||||
pw = body.get("password") or ""
|
pw = body.get("password") or ""
|
||||||
|
|||||||
+89
-21
@@ -569,6 +569,16 @@ async def auto_name_session(session_manager, sess):
|
|||||||
if not first_msg:
|
if not first_msg:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
from src.owner_identity import is_request_sentinel_owner
|
||||||
|
owner = getattr(sess, "owner", None)
|
||||||
|
invalid_owner = (
|
||||||
|
not isinstance(owner, str) or not owner.strip()
|
||||||
|
or owner != owner.strip() or is_request_sentinel_owner(owner)
|
||||||
|
)
|
||||||
|
if invalid_owner:
|
||||||
|
session_manager.update_session_name(sess.id, fallback_session_title(first_msg))
|
||||||
|
return
|
||||||
|
|
||||||
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
||||||
model_name = str(getattr(sess, "model", "") or "")
|
model_name = str(getattr(sess, "model", "") or "")
|
||||||
|
|
||||||
@@ -583,15 +593,25 @@ async def auto_name_session(session_manager, sess):
|
|||||||
except Exception:
|
except Exception:
|
||||||
_is_spec_model = False
|
_is_spec_model = False
|
||||||
if _is_spec_model and endpoint_url:
|
if _is_spec_model and endpoint_url:
|
||||||
_headers = getattr(sess, "headers", None)
|
|
||||||
if isinstance(_headers, str):
|
|
||||||
try:
|
|
||||||
_headers = json.loads(_headers)
|
|
||||||
except Exception:
|
|
||||||
_headers = None
|
|
||||||
try:
|
try:
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.endpoint_resolver import (build_chat_url, build_headers,
|
||||||
|
resolve_endpoint_runtime, resolve_owner_registered_endpoint)
|
||||||
|
from src.owner_identity import auth_disabled
|
||||||
|
|
||||||
|
owner = getattr(sess, "owner", None)
|
||||||
|
if not owner and not auth_disabled():
|
||||||
|
raise ValueError("Session has no owner")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
endpoint = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
||||||
|
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
||||||
|
title_url = build_chat_url(base)
|
||||||
|
title_headers = build_headers(key, base)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
title = await llm_call_async(
|
title = await llm_call_async(
|
||||||
endpoint_url,
|
title_url,
|
||||||
model_name,
|
model_name,
|
||||||
[
|
[
|
||||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
|
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
|
||||||
@@ -599,7 +619,7 @@ async def auto_name_session(session_manager, sess):
|
|||||||
],
|
],
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=32,
|
max_tokens=32,
|
||||||
headers=_headers or None,
|
headers=title_headers,
|
||||||
timeout=20,
|
timeout=20,
|
||||||
thinking_mode="off",
|
thinking_mode="off",
|
||||||
)
|
)
|
||||||
@@ -625,6 +645,14 @@ async def auto_name_session(session_manager, sess):
|
|||||||
return
|
return
|
||||||
|
|
||||||
owner = getattr(sess, "owner", None)
|
owner = getattr(sess, "owner", None)
|
||||||
|
from src.owner_identity import auth_disabled
|
||||||
|
if not owner and not auth_disabled():
|
||||||
|
title = fallback_session_title(first_msg)
|
||||||
|
session_manager.update_session_name(sess.id, title)
|
||||||
|
logger.info(f"Auto-named unowned session {sess.id} deterministically: {title}")
|
||||||
|
return
|
||||||
|
|
||||||
|
from src.task_endpoint import resolve_task_endpoint
|
||||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||||
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
||||||
)
|
)
|
||||||
@@ -632,24 +660,64 @@ async def auto_name_session(session_manager, sess):
|
|||||||
logger.debug("[auto-name] No model provided, skipping")
|
logger.debug("[auto-name] No model provided, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
sess_url = getattr(sess, "endpoint_url", None)
|
||||||
|
if t_url and sess_url and t_url == sess_url:
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.endpoint_resolver import (
|
||||||
|
build_chat_url,
|
||||||
|
build_headers,
|
||||||
|
resolve_endpoint,
|
||||||
|
resolve_endpoint_runtime,
|
||||||
|
resolve_owner_registered_endpoint,
|
||||||
|
)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
endpoint = resolve_owner_registered_endpoint(db, t_url, owner)
|
||||||
|
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
||||||
|
t_url = build_chat_url(base)
|
||||||
|
t_headers = build_headers(key, base)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("[auto-name] session fallback endpoint %s unauthorized for %s: %s", t_url, owner, e)
|
||||||
|
t_url, t_model, t_headers = None, None, None
|
||||||
|
for prefix in ("utility", "default"):
|
||||||
|
try:
|
||||||
|
u, m, h = resolve_endpoint(prefix, owner=owner)
|
||||||
|
if u and m:
|
||||||
|
t_url, t_model, t_headers = u, m, h
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if not t_url or not t_model:
|
||||||
|
title = fallback_session_title(first_msg)
|
||||||
|
session_manager.update_session_name(sess.id, title)
|
||||||
|
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
|
||||||
|
return
|
||||||
|
|
||||||
# max_tokens big enough that reasoning models (Minimax M2,
|
# max_tokens big enough that reasoning models (Minimax M2,
|
||||||
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
||||||
# plus the actual title — 200 used to clip them mid-reasoning
|
# plus the actual title — 200 used to clip them mid-reasoning
|
||||||
# so strip_think left an empty string and no rename happened.
|
# so strip_think left an empty string and no rename happened.
|
||||||
# Timeout matches: 60s gives slow local reasoners room to finish.
|
# Timeout matches: 60s gives slow local reasoners room to finish.
|
||||||
title = await llm_call_async(
|
try:
|
||||||
t_url,
|
title = await llm_call_async(
|
||||||
t_model,
|
t_url,
|
||||||
[
|
t_model,
|
||||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
|
[
|
||||||
{"role": "user", "content": first_msg},
|
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
|
||||||
],
|
{"role": "user", "content": first_msg},
|
||||||
temperature=0.3,
|
],
|
||||||
max_tokens=64,
|
temperature=0.3,
|
||||||
headers=t_headers,
|
max_tokens=64,
|
||||||
timeout=15,
|
headers=t_headers,
|
||||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
timeout=15,
|
||||||
)
|
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
|
||||||
|
title = ""
|
||||||
|
|
||||||
title = title.strip().strip('"\'').strip()
|
title = title.strip().strip('"\'').strip()
|
||||||
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
|
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ def _is_external_discovery_request(text: str) -> bool:
|
|||||||
|
|
||||||
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
||||||
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
|
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
|
||||||
|
_EDITOR_SESSION_RE = re.compile(r"[A-Za-z0-9@._:-]{1,128}")
|
||||||
|
|
||||||
|
|
||||||
def _mentions_pdf_url(value: str) -> bool:
|
def _mentions_pdf_url(value: str) -> bool:
|
||||||
@@ -2693,11 +2694,15 @@ def setup_chat_routes(
|
|||||||
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
||||||
# image-edit chat turn) may carry the open image-editor session id and a
|
# image-edit chat turn) may carry the open image-editor session id and a
|
||||||
# canvas snapshot so the editor tool family can drive it in-process.
|
# canvas snapshot so the editor tool family can drive it in-process.
|
||||||
editor_session = (
|
# The id is quoted into a system message, so accept only an
|
||||||
|
# identifier; any other value is treated as no open editor.
|
||||||
|
editor_session = str(
|
||||||
form_data.get("editor_session")
|
form_data.get("editor_session")
|
||||||
or form_data.get("editor_session_id")
|
or form_data.get("editor_session_id")
|
||||||
or ""
|
or ""
|
||||||
).strip()
|
).strip()
|
||||||
|
if not _EDITOR_SESSION_RE.fullmatch(editor_session):
|
||||||
|
editor_session = ""
|
||||||
photo_editor_requested = (
|
photo_editor_requested = (
|
||||||
str(form_data.get("photo_editor") or "").strip().lower()
|
str(form_data.get("photo_editor") or "").strip().lower()
|
||||||
in {"1", "true", "yes", "on"}
|
in {"1", "true", "yes", "on"}
|
||||||
@@ -3521,7 +3526,7 @@ def setup_chat_routes(
|
|||||||
if not _privs.get("can_use_documents", True):
|
if not _privs.get("can_use_documents", True):
|
||||||
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
||||||
if not _privs.get("can_generate_images", True):
|
if not _privs.get("can_generate_images", True):
|
||||||
disabled_tools.update({"generate_image", "edit_image"})
|
disabled_tools.update({"generate_image", "edit_image", "ai_edit_image"})
|
||||||
if not _privs.get("can_manage_memory", True):
|
if not _privs.get("can_manage_memory", True):
|
||||||
disabled_tools.update({"manage_memory", "manage_skills"})
|
disabled_tools.update({"manage_memory", "manage_skills"})
|
||||||
if not _privs.get("can_use_research", True):
|
if not _privs.get("can_use_research", True):
|
||||||
|
|||||||
+165
-1
@@ -14,6 +14,7 @@ from fastapi import HTTPException
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from routes._validators import validate_remote_host, validate_ssh_port
|
from routes._validators import validate_remote_host, validate_ssh_port
|
||||||
|
from routes.cookbook_output import HF_PARTIAL_BLOB_HELPER
|
||||||
from core.platform_compat import _ssh_exec_argv
|
from core.platform_compat import _ssh_exec_argv
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -378,6 +379,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
|||||||
"""
|
"""
|
||||||
lines = [
|
lines = [
|
||||||
"import json, os, re, shutil, subprocess, urllib.request",
|
"import json, os, re, shutil, subprocess, urllib.request",
|
||||||
|
*HF_PARTIAL_BLOB_HELPER.splitlines(),
|
||||||
"models = []",
|
"models = []",
|
||||||
"seen = set()",
|
"seen = set()",
|
||||||
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
|
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
|
||||||
@@ -436,8 +438,10 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
|||||||
" sz, nf, ic = 0, 0, False",
|
" sz, nf, ic = 0, 0, False",
|
||||||
" if os.path.isdir(blobs):",
|
" if os.path.isdir(blobs):",
|
||||||
" for f in os.scandir(blobs):",
|
" for f in os.scandir(blobs):",
|
||||||
|
" if f.name.endswith('.incomplete'):",
|
||||||
|
" if not is_partial_blob(f.path): continue",
|
||||||
|
" ic = True",
|
||||||
" 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",
|
|
||||||
" snap = os.path.join(cache, d, 'snapshots')",
|
" snap = os.path.join(cache, d, 'snapshots')",
|
||||||
" def snapshot_size():",
|
" def snapshot_size():",
|
||||||
" total, count, incomplete = 0, 0, False",
|
" total, count, incomplete = 0, 0, False",
|
||||||
@@ -1030,6 +1034,166 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
|
|||||||
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
|
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
|
||||||
|
|
||||||
|
|
||||||
|
ODYSSEUS_LLAMA_SHIM_MARKER = "# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike"
|
||||||
|
|
||||||
|
|
||||||
|
def _append_llama_server_resolution_definition_lines(runner_lines: list[str]) -> None:
|
||||||
|
"""Define the helper shell function that discovers, reconciles and prioritizes llama-server."""
|
||||||
|
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||||
|
runner_lines.append('_odysseus_resolve_llama_server() {')
|
||||||
|
runner_lines.append(' _odysseus_llama_bin=""')
|
||||||
|
runner_lines.append(f' local _marker={marker}')
|
||||||
|
runner_lines.append(' local _saved_ifs="$IFS"')
|
||||||
|
runner_lines.append(" IFS=':'")
|
||||||
|
runner_lines.append(' local -a _parts')
|
||||||
|
runner_lines.append(' read -r -a _parts <<< "$PATH"')
|
||||||
|
runner_lines.append(' IFS="$_saved_ifs"')
|
||||||
|
runner_lines.append(' local _dir _cand')
|
||||||
|
runner_lines.append(' for _dir in "${_parts[@]}"; do')
|
||||||
|
runner_lines.append(' [ -n "$_dir" ] || continue')
|
||||||
|
runner_lines.append(' _cand="$_dir/llama-server"')
|
||||||
|
runner_lines.append(' [ -x "$_cand" ] || continue')
|
||||||
|
runner_lines.append(' if [ -f "$_cand" ] && [ ! -L "$_cand" ] && grep -Fxq "$_marker" "$_cand" 2>/dev/null; then')
|
||||||
|
runner_lines.append(' continue')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' if "$_cand" --version >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' _odysseus_llama_bin="$_cand"')
|
||||||
|
runner_lines.append(' break')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' done')
|
||||||
|
runner_lines.append(' if [ -f "$HOME/bin/llama-server" ] && [ ! -L "$HOME/bin/llama-server" ] && grep -Fxq "$_marker" "$HOME/bin/llama-server" 2>/dev/null; then')
|
||||||
|
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||||
|
runner_lines.append(' echo "[odysseus] Native llama-server detected — retiring Odysseus compatibility shim in ~/bin"')
|
||||||
|
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||||
|
runner_lines.append(' hash -r')
|
||||||
|
runner_lines.append(' elif python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||||
|
runner_lines.append(' _odysseus_llama_bin="$HOME/bin/llama-server"')
|
||||||
|
runner_lines.append(' else')
|
||||||
|
runner_lines.append(' echo "[odysseus] Removing stale Odysseus llama-server shim (python3 -m llama_cpp.server unavailable)"')
|
||||||
|
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||||
|
runner_lines.append(' hash -r')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||||
|
runner_lines.append(' local _cand_dir')
|
||||||
|
runner_lines.append(' _cand_dir="$(dirname "$_odysseus_llama_bin")"')
|
||||||
|
runner_lines.append(' if [ "${PATH%%:*}" != "$_cand_dir" ]; then')
|
||||||
|
runner_lines.append(' export PATH="$_cand_dir:$PATH"')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' hash -r')
|
||||||
|
runner_lines.append(' return 0')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' return 1')
|
||||||
|
runner_lines.append('}')
|
||||||
|
|
||||||
|
|
||||||
|
def _append_llama_server_shim_definition_lines(runner_lines: list[str]) -> None:
|
||||||
|
"""Define the helper shell function that generates the Odysseus compatibility shim."""
|
||||||
|
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||||
|
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||||
|
runner_lines.append('_odysseus_create_llama_shim() {')
|
||||||
|
runner_lines.append(' local _target="$HOME/bin/llama-server"')
|
||||||
|
runner_lines.append(f' local _marker={marker}')
|
||||||
|
runner_lines.append(' if [ -e "$_target" ] || [ -L "$_target" ]; then')
|
||||||
|
runner_lines.append(' if [ -f "$_target" ] && [ ! -L "$_target" ] && grep -Fxq "$_marker" "$_target" 2>/dev/null; then')
|
||||||
|
runner_lines.append(' : # existing Odysseus shim — safe to replace')
|
||||||
|
runner_lines.append(' else')
|
||||||
|
runner_lines.append(' echo "[odysseus] WARNING: $_target already exists and is not an Odysseus-managed shim; leaving untouched." >&2')
|
||||||
|
runner_lines.append(' return 1')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' mkdir -p "$HOME/bin" || return 1')
|
||||||
|
runner_lines.append(' local _tmp')
|
||||||
|
runner_lines.append(' _tmp="$(mktemp "$HOME/bin/.llama-server.odysseus.tmp.XXXXXX")" || {')
|
||||||
|
runner_lines.append(' echo "[odysseus] ERROR: unable to create secure temporary llama-server shim in $HOME/bin" >&2')
|
||||||
|
runner_lines.append(' return 1')
|
||||||
|
runner_lines.append(' }')
|
||||||
|
runner_lines.append(" if ! cat > \"$_tmp\" <<'_ODY_LLAMA_SHIM_EOF'")
|
||||||
|
runner_lines.append('#!/usr/bin/env bash')
|
||||||
|
runner_lines.append(ODYSSEUS_LLAMA_SHIM_MARKER)
|
||||||
|
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
||||||
|
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
||||||
|
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
||||||
|
runner_lines.append('ARGS=()')
|
||||||
|
runner_lines.append('while [ $# -gt 0 ]; do')
|
||||||
|
runner_lines.append(' case "$1" in')
|
||||||
|
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
||||||
|
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
||||||
|
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
||||||
|
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
||||||
|
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
||||||
|
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
||||||
|
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
||||||
|
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
||||||
|
runner_lines.append(' esac')
|
||||||
|
runner_lines.append('done')
|
||||||
|
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
||||||
|
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
||||||
|
runner_lines.append(' then')
|
||||||
|
runner_lines.append(' rm -f -- "$_tmp"')
|
||||||
|
runner_lines.append(' return 1')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' chmod +x "$_tmp" || { rm -f -- "$_tmp"; return 1; }')
|
||||||
|
runner_lines.append(' mv -f -- "$_tmp" "$_target" || { rm -f -- "$_tmp"; return 1; }')
|
||||||
|
runner_lines.append(' hash -r')
|
||||||
|
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
||||||
|
runner_lines.append('}')
|
||||||
|
|
||||||
|
|
||||||
|
def _append_llama_server_shim_reconciliation_lines(runner_lines: list[str]) -> None:
|
||||||
|
"""Reconcile an existing ~/bin/llama-server if it is owned by Odysseus Cookbook.
|
||||||
|
|
||||||
|
Invariants:
|
||||||
|
- Never touch an arbitrary user-owned executable or symlink.
|
||||||
|
- If ~/bin/llama-server is an Odysseus generated shim:
|
||||||
|
- If a native llama-server is present and healthy elsewhere on PATH, remove the shim
|
||||||
|
so the native binary is used.
|
||||||
|
- If python3 -m llama_cpp.server is unavailable, remove the stale shim
|
||||||
|
and hash -r so the fallback/build path is triggered.
|
||||||
|
- If no healthy native server exists and llama_cpp.server is working, preserve it.
|
||||||
|
- If a healthy native candidate exists on PATH (even later in PATH), prioritize it and
|
||||||
|
ensure the launched executable is the validated candidate.
|
||||||
|
"""
|
||||||
|
runner_lines.append('[ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env')
|
||||||
|
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||||
|
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||||
|
runner_lines.append('fi')
|
||||||
|
runner_lines.append('_odysseus_resolve_llama_server || true')
|
||||||
|
|
||||||
|
|
||||||
|
def _append_llama_server_finalization_lines(runner_lines: list[str], platform_hint: str = "") -> None:
|
||||||
|
"""Reconcile post-install launcher availability and enforce fail-closed invariant.
|
||||||
|
|
||||||
|
Guarantees that if python3 -m llama_cpp.server is usable but the `llama-server`
|
||||||
|
command is unresolvable, the compatibility launcher is created. If no usable
|
||||||
|
launcher exists after all build/install attempts, sets ODYSSEUS_PREFLIGHT_EXIT=127.
|
||||||
|
"""
|
||||||
|
err_msg = (
|
||||||
|
f"ERROR: llama.cpp serving is not available on {platform_hint} after install attempts."
|
||||||
|
if platform_hint
|
||||||
|
else "ERROR: llama.cpp serving is not available after install/build attempts."
|
||||||
|
)
|
||||||
|
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||||
|
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||||
|
runner_lines.append('fi')
|
||||||
|
runner_lines.append('if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||||
|
runner_lines.append(' _odysseus_create_llama_shim')
|
||||||
|
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||||
|
runner_lines.append('fi')
|
||||||
|
runner_lines.append('if [ -z "$_odysseus_llama_bin" ]; then')
|
||||||
|
runner_lines.append(f' echo "{err_msg}"')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append('fi')
|
||||||
|
|
||||||
|
|
||||||
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> 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.
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ import re
|
|||||||
|
|
||||||
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Shared source embedded in local/remote inventory and status probes. A blank
|
||||||
|
# retry marker beside its completed, nonempty content-addressed blob is stale;
|
||||||
|
# a blank marker alone may be a download that has not received its first byte.
|
||||||
|
HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
|
||||||
|
if not path.endswith('.incomplete'):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
completed = path[:-len('.incomplete')]
|
||||||
|
return (os.path.getsize(path) != 0 or not os.path.isfile(completed)
|
||||||
|
or os.path.getsize(completed) == 0)
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
"""
|
||||||
|
|
||||||
# Probe scripts for the dead-session download check, run as
|
# Probe scripts for the dead-session download check, run as
|
||||||
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
|
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
|
||||||
# cache_root is the task's custom download dir, '' for the default HF cache.
|
# cache_root is the task's custom download dir, '' for the default HF cache.
|
||||||
@@ -15,7 +29,7 @@ _FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
|||||||
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
|
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
|
||||||
# the probe process's own environment knows nothing about it.
|
# the probe process's own environment knows nothing about it.
|
||||||
HF_CACHE_COMPLETE_PROBE = (
|
HF_CACHE_COMPLETE_PROBE = (
|
||||||
"import os,sys;"
|
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||||
"repo=sys.argv[1];"
|
"repo=sys.argv[1];"
|
||||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||||
@@ -24,18 +38,18 @@ HF_CACHE_COMPLETE_PROBE = (
|
|||||||
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
||||||
"inc=False;"
|
"inc=False;"
|
||||||
"blobs=os.path.join(d,'blobs');"
|
"blobs=os.path.join(d,'blobs');"
|
||||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||||
"sys.exit(0 if ok and not inc else 1)"
|
"sys.exit(0 if ok and not inc else 1)"
|
||||||
)
|
)
|
||||||
|
|
||||||
HF_CACHE_INCOMPLETE_PROBE = (
|
HF_CACHE_INCOMPLETE_PROBE = (
|
||||||
"import os,sys;"
|
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||||
"repo=sys.argv[1];"
|
"repo=sys.argv[1];"
|
||||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||||
"blobs=os.path.join(d,'blobs');"
|
"blobs=os.path.join(d,'blobs');"
|
||||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||||
"sys.exit(0 if inc else 1)"
|
"sys.exit(0 if inc else 1)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+17
-57
@@ -57,9 +57,11 @@ from routes.cookbook_helpers import (
|
|||||||
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||||
_diagnose_serve_output, run_ssh_command_async,
|
_diagnose_serve_output, run_ssh_command_async,
|
||||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
||||||
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
|
||||||
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
|
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
|
||||||
_normalize_llama_cpp_python_cache_types,
|
_normalize_llama_cpp_python_cache_types,
|
||||||
|
_append_llama_server_shim_definition_lines,
|
||||||
|
_append_llama_server_shim_reconciliation_lines,
|
||||||
|
_append_llama_server_finalization_lines,
|
||||||
ModelDownloadRequest, ServeRequest,
|
ModelDownloadRequest, ServeRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2161,7 +2163,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
ps_lines.append('}')
|
ps_lines.append('}')
|
||||||
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
|
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
|
||||||
ps_lines.append('# Auto-install llama-cpp-python if missing')
|
ps_lines.append('# Auto-install llama-cpp-python if missing')
|
||||||
ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}')
|
ps_lines.append('try { python -c "import llama_cpp.server" 2>$null } catch {}')
|
||||||
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
|
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
|
||||||
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
|
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
|
||||||
ps_lines.append(' python -m pip install llama-cpp-python[server]')
|
ps_lines.append(' python -m pip install llama-cpp-python[server]')
|
||||||
@@ -2247,14 +2249,17 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# ollama is found (otherwise macOS falls back to a slow source build).
|
# ollama is found (otherwise macOS falls back to a slow source build).
|
||||||
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
|
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
|
||||||
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||||
|
_append_llama_server_shim_definition_lines(runner_lines)
|
||||||
|
_append_llama_server_shim_reconciliation_lines(runner_lines)
|
||||||
runner_lines.append('if [ -d /data/data/com.termux ]; then')
|
runner_lines.append('if [ -d /data/data/com.termux ]; then')
|
||||||
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
|
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
|
||||||
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
runner_lines.append(' if ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||||
runner_lines.append(' pkg install -y cmake 2>/dev/null')
|
runner_lines.append(' pkg install -y cmake 2>/dev/null')
|
||||||
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
|
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
|
||||||
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
|
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
runner_lines.append('elif ! command -v llama-server &>/dev/null; then')
|
_append_llama_server_finalization_lines(runner_lines, platform_hint="Termux")
|
||||||
|
runner_lines.append('elif [ -z "$_odysseus_llama_bin" ]; then')
|
||||||
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
|
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
|
||||||
runner_lines.append(' mkdir -p ~/bin')
|
runner_lines.append(' mkdir -p ~/bin')
|
||||||
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
|
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
|
||||||
@@ -2271,8 +2276,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
|
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
|
||||||
# explicit so the binary is optimized (Metal auto-enables on macOS).
|
# explicit so the binary is optimized (Metal auto-enables on macOS).
|
||||||
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
|
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
|
||||||
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\')
|
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server')
|
||||||
runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
|
||||||
runner_lines.append(' else')
|
runner_lines.append(' else')
|
||||||
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
@@ -2296,58 +2300,13 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
# SHORT-CIRCUIT before the build/pip fallback: if the
|
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||||
# native binary is missing but llama_cpp Python is already
|
# If the native build failed, fall back to the Python bindings.
|
||||||
# installed, drop a wrapper at ~/bin/llama-server that
|
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||||
# translates llama-server CLI args to llama_cpp.server's
|
|
||||||
# underscore-style flags. The user's serve command stays
|
|
||||||
# `llama-server ...` and "just works" — no build, no cmake,
|
|
||||||
# no second install. This is the path that unblocks every
|
|
||||||
# remote where pip-installed llama-cpp-python is already
|
|
||||||
# working but Cookbook used to insist on a native binary.
|
|
||||||
runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then')
|
|
||||||
runner_lines.append(' mkdir -p ~/bin')
|
|
||||||
runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'')
|
|
||||||
runner_lines.append('#!/usr/bin/env bash')
|
|
||||||
runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike')
|
|
||||||
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
|
||||||
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
|
||||||
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
|
||||||
runner_lines.append('ARGS=()')
|
|
||||||
runner_lines.append('while [ $# -gt 0 ]; do')
|
|
||||||
runner_lines.append(' case "$1" in')
|
|
||||||
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
|
||||||
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
|
||||||
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
|
||||||
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
|
||||||
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
|
||||||
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
|
||||||
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
|
||||||
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
|
||||||
runner_lines.append(' esac')
|
|
||||||
runner_lines.append('done')
|
|
||||||
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
|
||||||
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
|
||||||
runner_lines.append(' chmod +x ~/bin/llama-server')
|
|
||||||
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
|
||||||
runner_lines.append(' fi')
|
|
||||||
runner_lines.append(' # If the native build failed, fall back to the Python bindings.')
|
|
||||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
|
||||||
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
|
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
|
||||||
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
|
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
_append_llama_server_finalization_lines(runner_lines)
|
||||||
runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."')
|
|
||||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
|
||||||
runner_lines.append(' fi')
|
|
||||||
runner_lines.append('fi')
|
runner_lines.append('fi')
|
||||||
elif re.search(r"\bollama\s+serve\b", req.cmd):
|
elif re.search(r"\bollama\s+serve\b", req.cmd):
|
||||||
handled_ollama_serve = True
|
handled_ollama_serve = True
|
||||||
@@ -4343,7 +4302,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
tmux output can stop at a stale progress line if the pane/session
|
tmux output can stop at a stale progress line if the pane/session
|
||||||
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
||||||
In that case, trust the cache shape: a snapshot directory with files
|
In that case, trust the cache shape: a snapshot directory with files
|
||||||
and no *.incomplete blobs means HuggingFace finished materializing the
|
and no unfinished blobs means HuggingFace finished materializing the
|
||||||
model. cache_root is the task's custom download dir — the runner
|
model. cache_root is the task's custom download dir — the runner
|
||||||
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
||||||
not wherever this probe's environment says.
|
not wherever this probe's environment says.
|
||||||
@@ -4368,7 +4327,8 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
"""Best-effort check for resumable HF partial blobs.
|
"""Best-effort check for resumable HF partial blobs.
|
||||||
|
|
||||||
A lost SSH/tmux session can leave a real download still incomplete.
|
A lost SSH/tmux session can leave a real download still incomplete.
|
||||||
Treat any *.incomplete blob as stronger evidence than stale
|
Treat unfinished blobs (excluding empty markers paired with a
|
||||||
|
completed blob) as stronger evidence than stale
|
||||||
"100%" lines in the captured pane output.
|
"100%" lines in the captured pane output.
|
||||||
"""
|
"""
|
||||||
if not repo_id or "/" not in repo_id:
|
if not repo_id or "/" not in repo_id:
|
||||||
|
|||||||
@@ -781,9 +781,11 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
|
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
|
||||||
data = {}
|
data = {}
|
||||||
ids = data.get("ids") or []
|
ids = data.get("ids") if isinstance(data, dict) else None
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(400, "No documents specified")
|
raise HTTPException(400, "No documents specified")
|
||||||
|
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
||||||
|
raise HTTPException(400, "ids must be a list of document ids")
|
||||||
_ext = {
|
_ext = {
|
||||||
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
|
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
|
||||||
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
|
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
|
||||||
|
|||||||
+292
-92
@@ -21,11 +21,12 @@ import asyncio
|
|||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from src import editor_style_guide as style_guide
|
from src import editor_style_guide as style_guide
|
||||||
@@ -1169,16 +1170,111 @@ def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
op = str(step.get("op") or "")
|
op = str(step.get("op") or "")
|
||||||
if op not in _OP_SET:
|
if op not in _OP_SET:
|
||||||
continue
|
continue
|
||||||
out.append({"op": op, "params": step.get("params") or {}})
|
params = step.get("params")
|
||||||
|
out.append({"op": op, "params": params if isinstance(params, dict) else {}})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _user(request: Request) -> Optional[str]:
|
async def _json_object(request: Request) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
from src.auth_helpers import get_current_user
|
body = await request.json()
|
||||||
return get_current_user(request)
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
raise HTTPException(400, "Expected a JSON object")
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Expected a JSON object")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt(value: Any) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if len(text) > 4096:
|
||||||
|
raise HTTPException(413, "Editor prompt is too long")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge_timeout(value: Any) -> float:
|
||||||
|
try:
|
||||||
|
timeout = float(value or _BRIDGE_DEFAULT_TIMEOUT)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
raise HTTPException(400, "Invalid bridge timeout")
|
||||||
|
if not math.isfinite(timeout) or timeout <= 0 or timeout > 900:
|
||||||
|
raise HTTPException(400, "Invalid bridge timeout")
|
||||||
|
return timeout
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin(request: Request) -> bool:
|
||||||
|
"""Return True only for authorized administrator browser sessions or auth-disabled single-user mode.
|
||||||
|
|
||||||
|
Delegated credentials (bearer API tokens) are explicitly rejected:
|
||||||
|
per src/tool_security.py, bearer tokens are long-lived credentials handed to
|
||||||
|
third parties/scripts and must remain capped at non-admin policies even
|
||||||
|
when minted by an administrator.
|
||||||
|
"""
|
||||||
|
# 1. Delegated credentials (bearer API tokens) must NEVER receive admin metadata privileges
|
||||||
|
if getattr(request.state, "api_token", False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. In-process internal tool bypass (loopback agent tools)
|
||||||
|
try:
|
||||||
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
|
||||||
|
import secrets
|
||||||
|
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||||
|
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
|
||||||
|
return True
|
||||||
|
if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER:
|
||||||
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Explicitly auth-disabled single-user operation (AUTH_ENABLED=false)
|
||||||
|
try:
|
||||||
|
from src.auth_helpers import _auth_disabled
|
||||||
|
if _auth_disabled():
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. Middleware require_admin
|
||||||
|
try:
|
||||||
|
from core.middleware import require_admin
|
||||||
|
require_admin(request)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 5. Direct session/user check when request.state.current_user is set
|
||||||
|
try:
|
||||||
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
|
if auth_mgr and getattr(auth_mgr, "is_configured", False):
|
||||||
|
from src.auth_helpers import get_current_user
|
||||||
|
user = get_current_user(request) or getattr(request.state, "current_user", None)
|
||||||
|
if user and getattr(auth_mgr, "is_admin", None) and auth_mgr.is_admin(user):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _user(request: Request) -> Optional[str]:
|
||||||
|
# Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner
|
||||||
|
if getattr(request.state, "api_token", False):
|
||||||
|
from src.auth_helpers import require_api_token_scope
|
||||||
|
return require_api_token_scope(request, "chat")
|
||||||
|
from src.auth_helpers import get_current_user, _auth_disabled
|
||||||
|
u = get_current_user(request)
|
||||||
|
if u:
|
||||||
|
return u
|
||||||
|
if _auth_disabled():
|
||||||
return None
|
return None
|
||||||
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
|
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||||
|
raise HTTPException(401, "Not authenticated")
|
||||||
|
client = getattr(request, "client", None)
|
||||||
|
host = (client.host if client else "") or ""
|
||||||
|
if host in ("127.0.0.1", "::1", "localhost", "testclient") or not host:
|
||||||
|
return None
|
||||||
|
raise HTTPException(401, "Not authenticated")
|
||||||
|
|
||||||
|
|
||||||
async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]:
|
async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]:
|
||||||
@@ -1206,17 +1302,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
if _EDITOR_PLANNER is None:
|
if _EDITOR_PLANNER is None:
|
||||||
set_editor_planner(_default_editor_planner)
|
set_editor_planner(_default_editor_planner)
|
||||||
|
|
||||||
# Probe the local editor-agent model server (non-blocking) so the planner
|
|
||||||
# can use it for vision planning when reachable.
|
|
||||||
editor_agent_model.startup_probe()
|
|
||||||
|
|
||||||
# scoped session -> list of that session's live subscriber queues; id -> result future.
|
# scoped session -> list of that session's live subscriber queues; id -> result future.
|
||||||
# A command is BROADCAST to every live subscriber (per-connection queue), never to a
|
# A command is BROADCAST to every live subscriber (per-connection queue), never to a
|
||||||
# single shared queue: EventSource reconnects overlap, and with one shared queue a
|
# single shared queue: EventSource reconnects overlap, and with one shared queue a
|
||||||
# command could be consumed by a stale subscriber while the active page got nothing.
|
# command could be consumed by a stale subscriber while the active page got nothing.
|
||||||
subscribers: Dict[str, List["asyncio.Queue[dict]"]] = {}
|
subscribers: Dict[Tuple[Optional[str], str], List["asyncio.Queue[dict]"]] = {}
|
||||||
backlog: Dict[str, List[dict]] = {}
|
backlog: Dict[Tuple[Optional[str], str], List[dict]] = {}
|
||||||
pending: Dict[str, asyncio.Future] = {}
|
pending: Dict[str, Tuple[Optional[str], asyncio.Future]] = {}
|
||||||
|
max_pending = 1024
|
||||||
|
|
||||||
def _subscribe(session: str) -> "asyncio.Queue[dict]":
|
def _subscribe(session: str) -> "asyncio.Queue[dict]":
|
||||||
q: "asyncio.Queue[dict]" = asyncio.Queue(maxsize=_QUEUE_MAX)
|
q: "asyncio.Queue[dict]" = asyncio.Queue(maxsize=_QUEUE_MAX)
|
||||||
@@ -1253,17 +1346,30 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
del bl[0: len(bl) - _QUEUE_MAX]
|
del bl[0: len(bl) - _QUEUE_MAX]
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def _scoped(session: Optional[str], owner: Optional[str]) -> str:
|
def _expire_command(session, cid, fut):
|
||||||
return f"{owner or 'anon'}:{session or 'default'}"
|
entry = pending.get(cid)
|
||||||
|
if entry is not None and entry[1] is fut:
|
||||||
|
pending.pop(cid, None)
|
||||||
|
if not fut.done():
|
||||||
|
fut.cancel()
|
||||||
|
queued = backlog.get(session)
|
||||||
|
if queued:
|
||||||
|
backlog[session] = [cmd for cmd in queued if cmd.get("id") != cid]
|
||||||
|
if not backlog[session]:
|
||||||
|
backlog.pop(session, None)
|
||||||
|
|
||||||
|
def _scoped(session: Optional[str], owner: Optional[str]) -> Tuple[Optional[str], str]:
|
||||||
|
# Usernames and session ids may both contain colons.
|
||||||
|
return (owner, str(session or 'default'))
|
||||||
|
|
||||||
|
def _scope_label(scope: Tuple[Optional[str], str]) -> str:
|
||||||
|
return f"{scope[0] or 'anon'}:{scope[1]}"
|
||||||
|
|
||||||
@router.post("/api/editor/plan")
|
@router.post("/api/editor/plan")
|
||||||
async def plan(request: Request) -> JSONResponse:
|
async def plan(request: Request) -> JSONResponse:
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
prompt = _prompt(body.get("prompt"))
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
prompt = str(body.get("prompt") or "").strip()
|
|
||||||
state = body.get("state") or {}
|
state = body.get("state") or {}
|
||||||
mode = str(body.get("mode") or "plan").strip().lower()
|
mode = str(body.get("mode") or "plan").strip().lower()
|
||||||
screenshot = body.get("screenshot")
|
screenshot = body.get("screenshot")
|
||||||
@@ -1297,9 +1403,13 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
raw = None
|
raw = None
|
||||||
_used = editor_agent_model.get_last_model()
|
_used = editor_agent_model.get_last_model()
|
||||||
model_used = (_used[0] if _used and _used[0]
|
|
||||||
else (agent_model or editor_agent_model.local_model()))
|
|
||||||
parsed = _parse_plan(raw)
|
parsed = _parse_plan(raw)
|
||||||
|
if _used and _used[0]:
|
||||||
|
model_used = _used[0]
|
||||||
|
elif parsed:
|
||||||
|
model_used = agent_model or editor_agent_model.local_model()
|
||||||
|
else:
|
||||||
|
model_used = "fallback" if agent_model else editor_agent_model.local_model()
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
parsed["agent_model"] = model_used
|
parsed["agent_model"] = model_used
|
||||||
if mode != "check":
|
if mode != "check":
|
||||||
@@ -1363,31 +1473,49 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
@router.post("/api/editor/bridge/command")
|
@router.post("/api/editor/bridge/command")
|
||||||
async def bridge_command(request: Request) -> JSONResponse:
|
async def bridge_command(request: Request) -> JSONResponse:
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
session = _scoped(body.get("session"), owner)
|
session = _scoped(body.get("session"), owner)
|
||||||
steps = _normalize_steps(body)
|
steps = _normalize_steps(body)
|
||||||
wait = bool(body.get("wait"))
|
wait = bool(body.get("wait"))
|
||||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
timeout = _bridge_timeout(body.get("timeout"))
|
||||||
ids: List[str] = []
|
ids: List[str] = []
|
||||||
wait_ids: List[str] = []
|
if len(pending) + len(steps) > max_pending:
|
||||||
|
raise HTTPException(429, "Too many pending editor commands")
|
||||||
for step in steps:
|
for step in steps:
|
||||||
cid = uuid.uuid4().hex
|
cid = uuid.uuid4().hex
|
||||||
ids.append(cid)
|
ids.append(cid)
|
||||||
if wait:
|
fut = asyncio.get_event_loop().create_future()
|
||||||
pending[cid] = asyncio.get_event_loop().create_future()
|
pending[cid] = (owner, fut)
|
||||||
wait_ids.append(cid)
|
|
||||||
_broadcast(session, {"id": cid, "op": step["op"], "params": step["params"]})
|
_broadcast(session, {"id": cid, "op": step["op"], "params": step["params"]})
|
||||||
if wait and len(wait_ids) == 1:
|
|
||||||
|
if wait and len(steps) == 1:
|
||||||
|
cid = ids[0]
|
||||||
|
entry = pending.get(cid)
|
||||||
|
fut = entry[1]
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(pending[wait_ids[0]], timeout=timeout)
|
res = await asyncio.wait_for(fut, timeout=timeout)
|
||||||
return JSONResponse(result)
|
if isinstance(res, dict):
|
||||||
|
res.setdefault("status", "completed" if res.get("ok") else "failed")
|
||||||
|
res.setdefault("completed", bool(res.get("ok")))
|
||||||
|
return JSONResponse(res)
|
||||||
|
return JSONResponse({"id": cid, "ok": True, "status": "completed", "completed": True, "result": res})
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pending.pop(wait_ids[0], None)
|
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid}, status_code=504)
|
||||||
return JSONResponse({"ok": False, "error": "timeout", "id": wait_ids[0]})
|
finally:
|
||||||
return JSONResponse({"queued": ids, "session": session})
|
_expire_command(session, cid, fut)
|
||||||
|
else:
|
||||||
|
for cid in ids:
|
||||||
|
entry = pending.get(cid)
|
||||||
|
if entry is not None:
|
||||||
|
asyncio.get_running_loop().call_later(timeout, _expire_command, session, cid, entry[1])
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"queued": ids,
|
||||||
|
"session": _scope_label(session),
|
||||||
|
"status": "queued",
|
||||||
|
"completed": False,
|
||||||
|
"ok": False,
|
||||||
|
})
|
||||||
|
|
||||||
@router.get("/api/editor/bridge/events")
|
@router.get("/api/editor/bridge/events")
|
||||||
async def bridge_events(request: Request, session: str = "default") -> StreamingResponse:
|
async def bridge_events(request: Request, session: str = "default") -> StreamingResponse:
|
||||||
@@ -1412,38 +1540,50 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
|
|
||||||
@router.post("/api/editor/bridge/result")
|
@router.post("/api/editor/bridge/result")
|
||||||
async def bridge_result(request: Request) -> JSONResponse:
|
async def bridge_result(request: Request) -> JSONResponse:
|
||||||
try:
|
owner = _user(request)
|
||||||
body = await request.json()
|
body = await _json_object(request)
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
cid = str(body.get("id") or "")
|
cid = str(body.get("id") or "")
|
||||||
fut = pending.pop(cid, None)
|
entry = pending.get(cid)
|
||||||
|
if entry is None:
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
if entry[0] != owner:
|
||||||
|
return JSONResponse({"ok": False, "error": "unknown command"}, status_code=404)
|
||||||
|
_, fut = entry
|
||||||
if fut is not None and not fut.done():
|
if fut is not None and not fut.done():
|
||||||
|
is_ok = bool(body.get("ok"))
|
||||||
fut.set_result({
|
fut.set_result({
|
||||||
"id": cid, "ok": bool(body.get("ok")),
|
"id": cid,
|
||||||
"result": body.get("result"), "error": body.get("error"),
|
"ok": is_ok,
|
||||||
|
"status": "completed" if is_ok else "failed",
|
||||||
|
"completed": is_ok,
|
||||||
|
"result": body.get("result"),
|
||||||
|
"error": body.get("error"),
|
||||||
"screenshot": body.get("screenshot"),
|
"screenshot": body.get("screenshot"),
|
||||||
})
|
})
|
||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
@router.post("/api/editor/bridge/await")
|
@router.post("/api/editor/bridge/await")
|
||||||
async def bridge_await(request: Request) -> JSONResponse:
|
async def bridge_await(request: Request) -> JSONResponse:
|
||||||
try:
|
owner = _user(request)
|
||||||
body = await request.json()
|
body = await _json_object(request)
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
cid = str(body.get("id") or "")
|
cid = str(body.get("id") or "")
|
||||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
timeout = _bridge_timeout(body.get("timeout"))
|
||||||
fut = pending.get(cid)
|
entry = pending.get(cid)
|
||||||
if fut is None:
|
if entry is None or entry[0] != owner:
|
||||||
fut = asyncio.get_event_loop().create_future()
|
return JSONResponse({"ok": False, "error": "unknown command", "status": "unknown", "completed": False}, status_code=404)
|
||||||
pending[cid] = fut
|
fut = entry[1]
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(fut, timeout=timeout)
|
result = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
|
||||||
return JSONResponse(result)
|
if pending.get(cid) == (owner, fut):
|
||||||
|
pending.pop(cid, None)
|
||||||
|
res = dict(result) if isinstance(result, dict) else {"result": result, "ok": True}
|
||||||
|
res.setdefault("status", "completed" if res.get("ok") else "failed")
|
||||||
|
res["completed"] = bool(res.get("ok"))
|
||||||
|
return JSONResponse(res)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pending.pop(cid, None)
|
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid})
|
||||||
return JSONResponse({"ok": False, "error": "timeout", "id": cid})
|
except asyncio.CancelledError:
|
||||||
|
return JSONResponse({"ok": False, "error": "cancelled", "status": "cancelled", "completed": False, "id": cid})
|
||||||
|
|
||||||
_AGENT_SYSTEM_PROMPT = (
|
_AGENT_SYSTEM_PROMPT = (
|
||||||
"You are the photo editor agent. You drive the open image editor and "
|
"You are the photo editor agent. You drive the open image editor and "
|
||||||
@@ -1494,11 +1634,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
overwritten.
|
overwritten.
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
from src.settings import get_setting
|
||||||
except Exception:
|
disabled = get_setting("disabled_tools", []) or []
|
||||||
body = {}
|
if not isinstance(disabled, (list, tuple, set)):
|
||||||
prompt = str(body.get("prompt") or "").strip()
|
disabled = []
|
||||||
|
if "plan_edit" in disabled:
|
||||||
|
raise HTTPException(403, "Editor tool is disabled")
|
||||||
|
prompt = _prompt(body.get("prompt"))
|
||||||
session = str(body.get("editor_session") or body.get("session") or "default")
|
session = str(body.get("editor_session") or body.get("session") or "default")
|
||||||
state = body.get("state") if isinstance(body.get("state"), dict) else {}
|
state = body.get("state") if isinstance(body.get("state"), dict) else {}
|
||||||
screenshot = body.get("screenshot")
|
screenshot = body.get("screenshot")
|
||||||
@@ -1506,10 +1649,10 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
||||||
# Optional user-picked PLANNER model (the editor agent/planner model).
|
# Optional user-picked PLANNER model (the editor agent/planner model).
|
||||||
agent_model = str(body.get("agent_model") or "").strip()
|
agent_model = str(body.get("agent_model") or "").strip()
|
||||||
tools_offered = _scoped_editor_tools()
|
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||||
scoped = _scoped(session, owner)
|
scoped = _scoped(session, owner)
|
||||||
trace: List[Dict[str, Any]] = [
|
trace: List[Dict[str, Any]] = [
|
||||||
{"type": "session", "session": scoped, "tools_offered": tools_offered},
|
{"type": "session", "session": _scope_label(scoped), "tools_offered": tools_offered},
|
||||||
]
|
]
|
||||||
if not prompt:
|
if not prompt:
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
@@ -1540,9 +1683,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
raw = None
|
raw = None
|
||||||
_used = editor_agent_model.get_last_model()
|
_used = editor_agent_model.get_last_model()
|
||||||
model_used = (_used[0] if _used and _used[0]
|
parsed = _parse_plan(raw)
|
||||||
else (agent_model or editor_agent_model.local_model()))
|
if _used and _used[0]:
|
||||||
plan = _parse_plan(raw) or _fallback_plan(prompt)
|
model_used = _used[0]
|
||||||
|
elif parsed:
|
||||||
|
model_used = agent_model or editor_agent_model.local_model()
|
||||||
|
else:
|
||||||
|
model_used = "fallback" if agent_model else editor_agent_model.local_model()
|
||||||
|
plan = parsed or _fallback_plan(prompt)
|
||||||
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
|
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
|
||||||
if _strengthened != plan.get("steps"):
|
if _strengthened != plan.get("steps"):
|
||||||
plan = {**plan, "steps": _strengthened,
|
plan = {**plan, "steps": _strengthened,
|
||||||
@@ -1601,8 +1749,19 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
"results": [],
|
"results": [],
|
||||||
"agent_model": model_used,
|
"agent_model": model_used,
|
||||||
})
|
})
|
||||||
|
# This route dispatches tools directly, outside execute_tool_block.
|
||||||
|
# Apply the same administrator tool switches before any step runs.
|
||||||
|
needed = {"plan_edit"}
|
||||||
|
for step in steps:
|
||||||
|
op = step.get("op")
|
||||||
|
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
|
||||||
|
if needed.intersection(disabled):
|
||||||
|
raise HTTPException(403, "Editor tool is disabled")
|
||||||
# No image provider: stop before any edit runs and tell the user plainly
|
# No image provider: stop before any edit runs and tell the user plainly
|
||||||
# (zero canvas changes; nothing to roll back).
|
# (zero canvas changes; nothing to roll back).
|
||||||
|
if _plan_uses_ai_content(steps):
|
||||||
|
from src.auth_helpers import require_privilege
|
||||||
|
require_privilege(request, "can_generate_images")
|
||||||
if (kind or _plan_uses_ai_content(steps)) and not provider_available:
|
if (kind or _plan_uses_ai_content(steps)) and not provider_available:
|
||||||
trace.append({"type": "blocked", "reason": "image_provider_unavailable"})
|
trace.append({"type": "blocked", "reason": "image_provider_unavailable"})
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
@@ -1618,6 +1777,12 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
"results": [],
|
"results": [],
|
||||||
"agent_model": model_used,
|
"agent_model": model_used,
|
||||||
})
|
})
|
||||||
|
needed = {"plan_edit"}
|
||||||
|
for step in steps:
|
||||||
|
op = step.get("op")
|
||||||
|
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
|
||||||
|
if needed.intersection(disabled):
|
||||||
|
raise HTTPException(403, "Editor tool is disabled")
|
||||||
results: List[Dict[str, Any]] = []
|
results: List[Dict[str, Any]] = []
|
||||||
for step in steps:
|
for step in steps:
|
||||||
op = step.get("op")
|
op = step.get("op")
|
||||||
@@ -1629,24 +1794,30 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
results.append({"op": op, "ok": False, "error": "editor tools unavailable"})
|
results.append({"op": op, "ok": False, "error": "editor tools unavailable"})
|
||||||
break
|
break
|
||||||
tool_payload = dict(params)
|
tool_payload = dict(params)
|
||||||
tool_payload.setdefault("session", session)
|
# Planner output is untrusted: it cannot redirect this turn to
|
||||||
|
# another editor session or override an explicit model choice.
|
||||||
|
tool_payload["session"] = session
|
||||||
# Route to the user-picked model when one is selected.
|
# Route to the user-picked model when one is selected.
|
||||||
if model_choice:
|
if model_choice:
|
||||||
tool_payload.setdefault("model", model_choice)
|
tool_payload["model"] = model_choice
|
||||||
# Hand the AI step the canvas image the client already captured
|
# Hand the AI step the canvas image the client already captured
|
||||||
# for this request. Without it the tool falls back to a separate
|
# for this request. Without it the tool falls back to a separate
|
||||||
# bridge screenshot, which is not reliably answered right after a
|
# bridge screenshot, which is not reliably answered right after a
|
||||||
# selection step (the masked path was losing the canvas image).
|
# selection step (the masked path was losing the canvas image).
|
||||||
if isinstance(screenshot, str) and screenshot.startswith("data:image/"):
|
if isinstance(screenshot, str) and screenshot.startswith("data:image/"):
|
||||||
tool_payload.setdefault("image", screenshot)
|
tool_payload["image"] = screenshot
|
||||||
|
else:
|
||||||
|
tool_payload.pop("image", None)
|
||||||
tool_cls = AiEditImageTool if op == "ai_edit_image" else GenerateImageLayerTool
|
tool_cls = AiEditImageTool if op == "ai_edit_image" else GenerateImageLayerTool
|
||||||
try:
|
try:
|
||||||
_desc, res = await tool_cls().execute(
|
_desc, res = await tool_cls().execute(
|
||||||
json.dumps(tool_payload),
|
json.dumps(tool_payload),
|
||||||
{"owner": owner, "session_id": scoped},
|
{"owner": owner, "session_id": _scope_label(scoped), "editor_session": session,
|
||||||
|
"incognito": body.get("incognito") is True},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
res = {"error": str(exc), "exit_code": 1}
|
logger.exception("Editor image operation failed")
|
||||||
|
res = {"error": "Editor image operation failed", "exit_code": 1}
|
||||||
res = res if isinstance(res, dict) else {}
|
res = res if isinstance(res, dict) else {}
|
||||||
ok = res.get("exit_code", 0) == 0
|
ok = res.get("exit_code", 0) == 0
|
||||||
step_result = {
|
step_result = {
|
||||||
@@ -1664,15 +1835,17 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
cid = uuid.uuid4().hex
|
cid = uuid.uuid4().hex
|
||||||
pending[cid] = asyncio.get_event_loop().create_future()
|
fut = asyncio.get_event_loop().create_future()
|
||||||
|
pending[cid] = (owner, fut)
|
||||||
_broadcast(scoped, {"id": cid, "op": op, "params": params})
|
_broadcast(scoped, {"id": cid, "op": op, "params": params})
|
||||||
try:
|
try:
|
||||||
res = await asyncio.wait_for(pending[cid], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
res = await asyncio.wait_for(pending[cid][1], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pending.pop(cid, None)
|
|
||||||
results.append({"op": op, "ok": False, "error": "timeout"})
|
results.append({"op": op, "ok": False, "error": "timeout"})
|
||||||
trace.append({"type": "step", "op": op, "ok": False, "error": "timeout"})
|
trace.append({"type": "step", "op": op, "ok": False, "error": "timeout"})
|
||||||
break
|
break
|
||||||
|
finally:
|
||||||
|
_expire_command(scoped, cid, fut)
|
||||||
results.append(res)
|
results.append(res)
|
||||||
trace.append({"type": "step", "op": op, "ok": res.get("ok"), "result": res.get("result")})
|
trace.append({"type": "step", "op": op, "ok": res.get("ok"), "result": res.get("result")})
|
||||||
if res.get("ok") is False:
|
if res.get("ok") is False:
|
||||||
@@ -1683,13 +1856,16 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
if steps and not any(r.get("ok") is False for r in results):
|
if steps and not any(r.get("ok") is False for r in results):
|
||||||
try:
|
try:
|
||||||
_info_id = uuid.uuid4().hex
|
_info_id = uuid.uuid4().hex
|
||||||
pending[_info_id] = asyncio.get_event_loop().create_future()
|
_info_fut = asyncio.get_event_loop().create_future()
|
||||||
|
pending[_info_id] = (owner, _info_fut)
|
||||||
_broadcast(scoped, {"id": _info_id, "op": "document.info", "params": {}})
|
_broadcast(scoped, {"id": _info_id, "op": "document.info", "params": {}})
|
||||||
_info = await asyncio.wait_for(pending[_info_id], timeout=60)
|
_info = await asyncio.wait_for(pending[_info_id][1], timeout=60)
|
||||||
if isinstance(_info, dict) and isinstance(_info.get("result"), dict):
|
if isinstance(_info, dict) and isinstance(_info.get("result"), dict):
|
||||||
final_state = _info["result"]
|
final_state = _info["result"]
|
||||||
except Exception:
|
except Exception:
|
||||||
final_state = {}
|
final_state = {}
|
||||||
|
finally:
|
||||||
|
_expire_command(scoped, _info_id, _info_fut)
|
||||||
planned_reply = reply
|
planned_reply = reply
|
||||||
reply = _verified_reply(prompt, steps, results, final_state, planned_reply)
|
reply = _verified_reply(prompt, steps, results, final_state, planned_reply)
|
||||||
if special_note and special_note.strip() not in reply:
|
if special_note and special_note.strip() not in reply:
|
||||||
@@ -1726,6 +1902,7 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
|
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
|
is_admin = _is_admin(request)
|
||||||
models: List[Dict[str, Any]] = []
|
models: List[Dict[str, Any]] = []
|
||||||
seen: set = set()
|
seen: set = set()
|
||||||
try:
|
try:
|
||||||
@@ -1748,22 +1925,32 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
# recognisably image-capable — the same test the request
|
# recognisably image-capable — the same test the request
|
||||||
# router uses.
|
# router uses.
|
||||||
image_endpoint = getattr(ep, "model_type", None) == "image"
|
image_endpoint = getattr(ep, "model_type", None) == "image"
|
||||||
provider = getattr(ep, "name", None) or getattr(ep, "base_url", "") or "image"
|
endpoint_id = getattr(ep, "id", None)
|
||||||
|
provider = getattr(ep, "name", None)
|
||||||
|
if not provider:
|
||||||
|
provider = (
|
||||||
|
getattr(ep, "base_url", "")
|
||||||
|
if is_admin
|
||||||
|
else (f"Endpoint {endpoint_id}" if endpoint_id else "image")
|
||||||
|
)
|
||||||
|
provider = provider or "image"
|
||||||
for name in _endpoint_enabled_models(ep):
|
for name in _endpoint_enabled_models(ep):
|
||||||
if not image_endpoint and not looks_like_image_generation_model(name):
|
if not image_endpoint and not looks_like_image_generation_model(name):
|
||||||
continue
|
continue
|
||||||
key = (provider, name)
|
key = (str(endpoint_id or provider), name)
|
||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
models.append({
|
entry = {
|
||||||
"id": name,
|
"id": name,
|
||||||
"label": name,
|
"label": name,
|
||||||
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
|
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"endpoint_id": getattr(ep, "id", None),
|
"endpoint_id": endpoint_id,
|
||||||
"base_url": getattr(ep, "base_url", "") or "",
|
}
|
||||||
})
|
if is_admin:
|
||||||
|
entry["base_url"] = getattr(ep, "base_url", "") or ""
|
||||||
|
models.append(entry)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1774,42 +1961,55 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
async def editor_agent_models(request: Request) -> JSONResponse:
|
async def editor_agent_models(request: Request) -> JSONResponse:
|
||||||
"""List the models the editor agent/planner can run on.
|
"""List the models the editor agent/planner can run on.
|
||||||
|
|
||||||
Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is
|
Each entry is ``{id, label, source, vision}`` where ``id`` is
|
||||||
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
|
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
|
||||||
``source`` is ``local`` (the local Qwen3.5-9B vision server) or
|
``source`` is ``local`` (the local Qwen3.5-9B vision server) or
|
||||||
``hosted`` (the configured default/utility chat model that the planner
|
``hosted`` (the configured default/utility chat model that the planner
|
||||||
falls back to).
|
falls back to).
|
||||||
|
For non-admin users, private URLs and internal probe status are omitted.
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
|
is_admin = _is_admin(request)
|
||||||
try:
|
try:
|
||||||
models = await editor_agent_model.list_editor_agent_models(owner)
|
models = await editor_agent_model.list_editor_agent_models(owner)
|
||||||
|
if not is_admin:
|
||||||
|
sanitized = []
|
||||||
|
for m in models:
|
||||||
|
m_copy = dict(m)
|
||||||
|
m_copy.pop("base_url", None)
|
||||||
|
sanitized.append(m_copy)
|
||||||
|
models = sanitized
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("editor agent-models lookup failed", exc_info=True)
|
logger.warning("editor agent-models lookup failed", exc_info=True)
|
||||||
models = []
|
models = []
|
||||||
|
|
||||||
|
probe_info = {}
|
||||||
|
if is_admin:
|
||||||
|
probe_info = editor_agent_model.probe_status()
|
||||||
|
if not probe_info:
|
||||||
|
editor_agent_model.startup_probe()
|
||||||
|
probe_info = editor_agent_model.probe_status()
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"models": models,
|
"models": models,
|
||||||
"probe": editor_agent_model.probe_status(),
|
"probe": probe_info,
|
||||||
})
|
})
|
||||||
|
|
||||||
@router.post("/api/editor/self-check")
|
@router.post("/api/editor/self-check")
|
||||||
async def editor_self_check(request: Request) -> JSONResponse:
|
async def editor_self_check(request: Request) -> JSONResponse:
|
||||||
"""Same-origin VL self-check: compare BEFORE/AFTER against the intent.
|
"""Same-origin VL self-check: compare BEFORE/AFTER against the intent.
|
||||||
|
|
||||||
The local VL server (seneca :8097) sends no CORS headers, so the browser
|
A configured vision server may not provide browser CORS headers. This route
|
||||||
cannot call it directly and the self-check silently "passed". This route
|
|
||||||
proxies the two images + intent to the agent VLM and returns
|
proxies the two images + intent to the agent VLM and returns
|
||||||
``{done, issues, retry_hint, text}`` (``done=True`` with
|
``{done, issues, retry_hint, text}`` (``done=True`` with
|
||||||
``reason="unavailable"`` on any failure so the client never loops).
|
``reason="unavailable"`` on any failure so the client never loops).
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
result = await editor_agent_model.self_check(
|
result = await editor_agent_model.self_check(
|
||||||
body.get("before"),
|
body.get("before"),
|
||||||
body.get("after"),
|
body.get("after"),
|
||||||
str(body.get("prompt") or body.get("intent") or ""),
|
_prompt(body.get("prompt") or body.get("intent")),
|
||||||
model=str(body.get("model") or "").strip() or None,
|
model=str(body.get("model") or "").strip() or None,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
|
|||||||
+133
-73
@@ -1827,18 +1827,25 @@ def setup_email_routes():
|
|||||||
# a cached message body.
|
# a cached message body.
|
||||||
return (account_id or "", folder, str(uid), owner)
|
return (account_id or "", folder, str(uid), owner)
|
||||||
|
|
||||||
def _list_cache_get(key):
|
|
||||||
v = _LIST_CACHE.get(key)
|
|
||||||
if not v: return None
|
|
||||||
if v[0] < _time.monotonic():
|
|
||||||
_LIST_CACHE.pop(key, None)
|
|
||||||
return None
|
|
||||||
return v[1]
|
|
||||||
|
|
||||||
_LIST_STALE = {}
|
_LIST_STALE = {}
|
||||||
|
_LIST_LOCK = _threading.RLock()
|
||||||
|
_LIST_CACHE_LOCK = _LIST_LOCK
|
||||||
|
# Invalidation fences reads already in flight, including background refresh.
|
||||||
|
_LIST_EPOCH = [0]
|
||||||
|
_LIST_CACHE_GENERATION = _LIST_EPOCH
|
||||||
|
|
||||||
|
def _list_cache_get(key):
|
||||||
|
with _LIST_LOCK:
|
||||||
|
v = _LIST_CACHE.get(key)
|
||||||
|
if not v: return None
|
||||||
|
if v[0] < _time.monotonic():
|
||||||
|
_LIST_CACHE.pop(key, None)
|
||||||
|
return None
|
||||||
|
return v[1]
|
||||||
|
|
||||||
def _list_stale_get(key):
|
def _list_stale_get(key):
|
||||||
return _LIST_STALE.get(key)
|
with _LIST_LOCK:
|
||||||
|
return _LIST_STALE.get(key)
|
||||||
|
|
||||||
def _list_stale_put(key, value):
|
def _list_stale_put(key, value):
|
||||||
_LIST_STALE[key] = value
|
_LIST_STALE[key] = value
|
||||||
@@ -1849,13 +1856,13 @@ def setup_email_routes():
|
|||||||
_LIST_REFRESHING = set()
|
_LIST_REFRESHING = set()
|
||||||
|
|
||||||
def _list_cache_put(key, value):
|
def _list_cache_put(key, value):
|
||||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
with _LIST_LOCK:
|
||||||
# Also snapshot the last-good response for stale-while-revalidate.
|
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||||
_list_stale_put(key, value)
|
# Also snapshot the last-good response for stale-while-revalidate.
|
||||||
# Cap size
|
_list_stale_put(key, value)
|
||||||
if len(_LIST_CACHE) > 64:
|
if len(_LIST_CACHE) > 64:
|
||||||
for k in list(_LIST_CACHE.keys())[:-32]:
|
for k in list(_LIST_CACHE.keys())[:-32]:
|
||||||
_LIST_CACHE.pop(k, None)
|
_LIST_CACHE.pop(k, None)
|
||||||
|
|
||||||
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
|
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
|
||||||
"""Refresh a list snapshot off the event loop, at most once per key.
|
"""Refresh a list snapshot off the event loop, at most once per key.
|
||||||
@@ -1863,15 +1870,20 @@ def setup_email_routes():
|
|||||||
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
|
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
|
||||||
the TTL cache and the stale snapshot so the next list view is warm.
|
the TTL cache and the stale snapshot so the next list view is warm.
|
||||||
"""
|
"""
|
||||||
if cache_key in _LIST_REFRESHING:
|
with _LIST_LOCK:
|
||||||
return
|
if cache_key in _LIST_REFRESHING:
|
||||||
_LIST_REFRESHING.add(cache_key)
|
return
|
||||||
|
_LIST_REFRESHING.add(cache_key)
|
||||||
|
epoch = _LIST_EPOCH[0]
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
try:
|
try:
|
||||||
refreshed = _list_emails_sync(*sync_args)
|
refreshed = _list_emails_sync(*sync_args)
|
||||||
if refreshed and not refreshed.get("error"):
|
if refreshed and not refreshed.get("error"):
|
||||||
_list_cache_put(cache_key, refreshed)
|
with _LIST_LOCK:
|
||||||
|
if epoch != _LIST_EPOCH[0]:
|
||||||
|
return
|
||||||
|
_list_cache_put(cache_key, refreshed)
|
||||||
try:
|
try:
|
||||||
_schedule_recent_email_warm(
|
_schedule_recent_email_warm(
|
||||||
refreshed.get("emails") or [], folder, account_id, owner)
|
refreshed.get("emails") or [], folder, account_id, owner)
|
||||||
@@ -1880,7 +1892,8 @@ def setup_email_routes():
|
|||||||
except Exception as exc: # pragma: no cover - background best effort
|
except Exception as exc: # pragma: no cover - background best effort
|
||||||
logger.debug("email list background refresh failed: %s", exc)
|
logger.debug("email list background refresh failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
_LIST_REFRESHING.discard(cache_key)
|
with _LIST_LOCK:
|
||||||
|
_LIST_REFRESHING.discard(cache_key)
|
||||||
|
|
||||||
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
||||||
|
|
||||||
@@ -1917,37 +1930,74 @@ def setup_email_routes():
|
|||||||
hook(account_id, folder)
|
hook(account_id, folder)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if account_id is None and folder is None:
|
with _LIST_LOCK:
|
||||||
_LIST_CACHE.clear()
|
_LIST_EPOCH[0] += 1
|
||||||
return
|
if account_id is None and folder is None:
|
||||||
for k in list(_LIST_CACHE.keys()):
|
_LIST_CACHE.clear()
|
||||||
k_acct = k[0] if len(k) > 0 else ""
|
_LIST_STALE.clear()
|
||||||
k_folder = k[1] if len(k) > 1 else ""
|
return
|
||||||
if (account_id is None or k_acct == (account_id or "")) and \
|
for cache in (_LIST_CACHE, _LIST_STALE):
|
||||||
(folder is None or k_folder == folder):
|
for k in list(cache.keys()):
|
||||||
_LIST_CACHE.pop(k, None)
|
k_acct = k[0] if len(k) > 0 else ""
|
||||||
|
k_folder = k[1] if len(k) > 1 else ""
|
||||||
|
if (account_id is None or k_acct == (account_id or "")) and \
|
||||||
|
(folder is None or k_folder == folder):
|
||||||
|
cache.pop(k, None)
|
||||||
|
|
||||||
|
def _index_update_flags(owner, account_id, folder, uid, flag, add):
|
||||||
|
with _LIST_LOCK:
|
||||||
|
_email_index_update_flags(owner, account_id, folder, uid, flag, add)
|
||||||
|
_invalidate_list_cache(account_id, folder)
|
||||||
|
|
||||||
|
def _index_delete(owner, account_id, folder, uid):
|
||||||
|
with _LIST_LOCK:
|
||||||
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
|
_invalidate_list_cache(account_id, folder)
|
||||||
|
|
||||||
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
|
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
|
||||||
uid_s = str(uid)
|
uid_s = str(uid)
|
||||||
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
with _LIST_LOCK:
|
||||||
if key[0] != (account_id or "") or key[1] != folder:
|
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
||||||
continue
|
if key[0] != (account_id or "") or key[1] != folder:
|
||||||
emails = list((value or {}).get("emails") or [])
|
continue
|
||||||
changed = False
|
emails = list((value or {}).get("emails") or [])
|
||||||
if seen and key[2] == "unread":
|
changed = False
|
||||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
if seen and key[2] == "unread":
|
||||||
if len(kept) != len(emails):
|
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||||
value = dict(value)
|
if len(kept) != len(emails):
|
||||||
value["emails"] = kept
|
value = dict(value)
|
||||||
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
value["emails"] = kept
|
||||||
changed = True
|
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||||
else:
|
|
||||||
for e in emails:
|
|
||||||
if str((e or {}).get("uid") or "") == uid_s:
|
|
||||||
e["is_read"] = bool(seen)
|
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
else:
|
||||||
_LIST_CACHE[key] = (expires_at, value)
|
for e in emails:
|
||||||
|
if str((e or {}).get("uid") or "") == uid_s:
|
||||||
|
e["is_read"] = bool(seen)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
_LIST_CACHE[key] = (expires_at, value)
|
||||||
|
if key in _LIST_STALE:
|
||||||
|
_LIST_STALE[key] = value
|
||||||
|
|
||||||
|
for key, value in list(_LIST_STALE.items()):
|
||||||
|
if key[0] != (account_id or "") or key[1] != folder:
|
||||||
|
continue
|
||||||
|
emails = list((value or {}).get("emails") or [])
|
||||||
|
changed = False
|
||||||
|
if seen and key[2] == "unread":
|
||||||
|
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||||
|
if len(kept) != len(emails):
|
||||||
|
value = dict(value)
|
||||||
|
value["emails"] = kept
|
||||||
|
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
for e in emails:
|
||||||
|
if str((e or {}).get("uid") or "") == uid_s:
|
||||||
|
e["is_read"] = bool(seen)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
_LIST_STALE[key] = value
|
||||||
|
|
||||||
def _read_cache_get(key):
|
def _read_cache_get(key):
|
||||||
v = _READ_CACHE.get(key)
|
v = _READ_CACHE.get(key)
|
||||||
@@ -2327,6 +2377,8 @@ def setup_email_routes():
|
|||||||
SECURITY: `owner` is propagated so when `account_id` is missing,
|
SECURITY: `owner` is propagated so when `account_id` is missing,
|
||||||
the fallback config lookup is scoped to this user's accounts only.
|
the fallback config lookup is scoped to this user's accounts only.
|
||||||
"""
|
"""
|
||||||
|
with _LIST_CACHE_LOCK:
|
||||||
|
index_generation = _LIST_CACHE_GENERATION[0]
|
||||||
conn = None
|
conn = None
|
||||||
conn_ok = False
|
conn_ok = False
|
||||||
try:
|
try:
|
||||||
@@ -2621,7 +2673,9 @@ def setup_email_routes():
|
|||||||
if parsed:
|
if parsed:
|
||||||
fetched_emails.append(parsed)
|
fetched_emails.append(parsed)
|
||||||
cached_rows[parsed["uid"]] = parsed
|
cached_rows[parsed["uid"]] = parsed
|
||||||
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
with _LIST_CACHE_LOCK:
|
||||||
|
if index_generation == _LIST_CACHE_GENERATION[0]:
|
||||||
|
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
||||||
for uid_num in uid_order:
|
for uid_num in uid_order:
|
||||||
row = cached_rows.get(uid_num)
|
row = cached_rows.get(uid_num)
|
||||||
if not row:
|
if not row:
|
||||||
@@ -2915,7 +2969,7 @@ def setup_email_routes():
|
|||||||
# with a `stale` marker and refresh in the background so the next view
|
# with a `stale` marker and refresh in the background so the next view
|
||||||
# is warm. Blocking IMAP only remains for a genuine cold start with no
|
# is warm. Blocking IMAP only remains for a genuine cold start with no
|
||||||
# snapshot and no indexed rows (e.g. a brand-new account/folder).
|
# snapshot and no indexed rows (e.g. a brand-new account/folder).
|
||||||
if not from_addr and not has_attachments and not (date_from or date_to):
|
if not manual_refresh and not from_addr and not has_attachments and not (date_from or date_to):
|
||||||
snapshot = _list_stale_get(ck)
|
snapshot = _list_stale_get(ck)
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
try:
|
try:
|
||||||
@@ -2950,6 +3004,8 @@ def setup_email_routes():
|
|||||||
ck, folder, account_id, owner,
|
ck, folder, account_id, owner,
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
|
with _LIST_CACHE_LOCK:
|
||||||
|
cache_generation = _LIST_CACHE_GENERATION[0]
|
||||||
result = await _asyncio.to_thread(
|
result = await _asyncio.to_thread(
|
||||||
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
||||||
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
||||||
@@ -2958,7 +3014,9 @@ def setup_email_routes():
|
|||||||
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
|
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
|
||||||
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
|
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
|
||||||
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
|
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
|
||||||
_list_cache_put(ck, result)
|
with _LIST_CACHE_LOCK:
|
||||||
|
if cache_generation == _LIST_CACHE_GENERATION[0]:
|
||||||
|
_list_cache_put(ck, result)
|
||||||
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
|
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
|
||||||
if elapsed_ms > 1500:
|
if elapsed_ms > 1500:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -3306,7 +3364,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
||||||
if moved:
|
if moved:
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
||||||
@@ -3323,7 +3381,7 @@ def setup_email_routes():
|
|||||||
if deleted:
|
if deleted:
|
||||||
conn.expunge()
|
conn.expunge()
|
||||||
if deleted:
|
if deleted:
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
delete_error = "Email was unsubscribed but could not be moved to Trash"
|
delete_error = "Email was unsubscribed but could not be moved to Trash"
|
||||||
@@ -3405,7 +3463,7 @@ def setup_email_routes():
|
|||||||
if moved:
|
if moved:
|
||||||
changed += 1
|
changed += 1
|
||||||
cleaned_uids.append(uid)
|
cleaned_uids.append(uid)
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -3494,6 +3552,8 @@ def setup_email_routes():
|
|||||||
folder (Gmail does), we transparently swap to All Mail so the
|
folder (Gmail does), we transparently swap to All Mail so the
|
||||||
search surfaces archived / labelled emails too. Plain IMAP
|
search surfaces archived / labelled emails too. Plain IMAP
|
||||||
accounts fall back to whatever folder the caller specified."""
|
accounts fall back to whatever folder the caller specified."""
|
||||||
|
with _LIST_CACHE_LOCK:
|
||||||
|
index_generation = _LIST_CACHE_GENERATION[0]
|
||||||
if not q or len(q) < 2:
|
if not q or len(q) < 2:
|
||||||
return {"emails": [], "total": 0, "query": q}
|
return {"emails": [], "total": 0, "query": q}
|
||||||
# CRLF in q would terminate the IMAP command early — reject defensively.
|
# CRLF in q would terminate the IMAP command early — reject defensively.
|
||||||
@@ -3637,7 +3697,9 @@ def setup_email_routes():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error parsing search result {uid}: {e}")
|
logger.warning(f"Error parsing search result {uid}: {e}")
|
||||||
continue
|
continue
|
||||||
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
with _LIST_CACHE_LOCK:
|
||||||
|
if index_generation == _LIST_CACHE_GENERATION[0]:
|
||||||
|
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"emails": emails,
|
"emails": emails,
|
||||||
@@ -3774,8 +3836,7 @@ def setup_email_routes():
|
|||||||
# accepted it, so the index and list cache cannot drift ahead of
|
# accepted it, so the index and list cache cannot drift ahead of
|
||||||
# the mailbox.
|
# the mailbox.
|
||||||
if mark_seen and not mark_seen_failed:
|
if mark_seen and not mark_seen_failed:
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
_update_list_cache_seen(account_id, folder, uid, True)
|
|
||||||
|
|
||||||
related_attachments = []
|
related_attachments = []
|
||||||
if full and not _has_visible_attachments(msg):
|
if full and not _has_visible_attachments(msg):
|
||||||
@@ -3914,8 +3975,7 @@ def setup_email_routes():
|
|||||||
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
return False
|
return False
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
_update_list_cache_seen(account_id, folder, uid, True)
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
||||||
@@ -5147,7 +5207,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Seen", add=False):
|
if not _store_email_flag(conn, uid, "\\Seen", add=False):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5167,7 +5227,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True, "flagged": bool(on)}
|
return {"success": True, "flagged": bool(on)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5185,7 +5245,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Seen", add=True):
|
if not _store_email_flag(conn, uid, "\\Seen", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5205,7 +5265,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _move_email_message(conn, uid, "Archive", role="archive"):
|
if not _move_email_message(conn, uid, "Archive", role="archive"):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5236,7 +5296,7 @@ def setup_email_routes():
|
|||||||
# click or by another mailbox client; it is already gone
|
# click or by another mailbox client; it is already gone
|
||||||
# from the requested folder, so do not trap the UI on a
|
# from the requested folder, so do not trap the UI on a
|
||||||
# permanent “Email not found” error.
|
# permanent “Email not found” error.
|
||||||
_email_index_delete(owner, account_id, folder, str(uid))
|
_index_delete(owner, account_id, folder, str(uid))
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True, "already_deleted": True}
|
return {"success": True, "already_deleted": True}
|
||||||
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
|
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
|
||||||
@@ -5254,9 +5314,9 @@ def setup_email_routes():
|
|||||||
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
|
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
|
||||||
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
|
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
|
||||||
return {"success": False, "error": "Could not move email to Trash"}
|
return {"success": False, "error": "Could not move email to Trash"}
|
||||||
_email_index_delete(owner, account_id, folder, resolved_uid)
|
_index_delete(owner, account_id, folder, resolved_uid)
|
||||||
if resolved_uid != str(uid):
|
if resolved_uid != str(uid):
|
||||||
_email_index_delete(owner, account_id, folder, str(uid))
|
_index_delete(owner, account_id, folder, str(uid))
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5272,7 +5332,7 @@ def setup_email_routes():
|
|||||||
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
|
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
conn.expunge()
|
conn.expunge()
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5340,7 +5400,7 @@ def setup_email_routes():
|
|||||||
pending_expunge.append(uid)
|
pending_expunge.append(uid)
|
||||||
continue
|
continue
|
||||||
deleted_uids.append(uid)
|
deleted_uids.append(uid)
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
except Exception:
|
except Exception:
|
||||||
failed_uids.append(uid)
|
failed_uids.append(uid)
|
||||||
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
||||||
@@ -5362,7 +5422,7 @@ def setup_email_routes():
|
|||||||
if expunge_ok:
|
if expunge_ok:
|
||||||
for uid in pending_expunge:
|
for uid in pending_expunge:
|
||||||
deleted_uids.append(uid)
|
deleted_uids.append(uid)
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
else:
|
else:
|
||||||
# Prioritize preservation over cleanup: unflag \Deleted from our
|
# Prioritize preservation over cleanup: unflag \Deleted from our
|
||||||
# copied messages so future EXPUNGE operations do not destroy them.
|
# copied messages so future EXPUNGE operations do not destroy them.
|
||||||
@@ -5481,7 +5541,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _move_email_message(conn, uid, dest):
|
if not _move_email_message(conn, uid, dest):
|
||||||
return {"success": False, "error": f"Failed to move to {dest}"}
|
return {"success": False, "error": f"Failed to move to {dest}"}
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5567,7 +5627,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Answered", add=True):
|
if not _store_email_flag(conn, uid, "\\Answered", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
||||||
_clear_done_response_tags(owner, account_id, folder, uid)
|
_clear_done_response_tags(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
@@ -5583,7 +5643,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Answered", add=False):
|
if not _store_email_flag(conn, uid, "\\Answered", add=False):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -6250,7 +6310,7 @@ def setup_email_routes():
|
|||||||
try:
|
try:
|
||||||
st, _sel = imap.select(_q(_source_folder), readonly=False)
|
st, _sel = imap.select(_q(_source_folder), readonly=False)
|
||||||
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
|
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
|
||||||
_email_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
||||||
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
|
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
|
||||||
_invalidate_list_cache(_account_id, _source_folder)
|
_invalidate_list_cache(_account_id, _source_folder)
|
||||||
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
|
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ def _split_family_token(token):
|
|||||||
def _derive_family(filename):
|
def _derive_family(filename):
|
||||||
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
|
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
|
||||||
name = os.path.splitext(filename)[0]
|
name = os.path.splitext(filename)[0]
|
||||||
# Strip common weight/style suffixes
|
# Strip weight/style suffixes, including BoldItalic and Bold-Italic.
|
||||||
name = re.sub(
|
name = re.sub(
|
||||||
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)$',
|
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)'
|
||||||
|
r'(?:[-_ ]?(?:Italic|Oblique))?$',
|
||||||
'', name, flags=re.IGNORECASE
|
'', name, flags=re.IGNORECASE
|
||||||
)
|
)
|
||||||
# Replace dashes/underscores with spaces
|
# Replace dashes/underscores with spaces
|
||||||
|
|||||||
@@ -683,9 +683,15 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st
|
|||||||
configured.update(v for v in values if isinstance(v, str))
|
configured.update(v for v in values if isinstance(v, str))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
if model not in configured or not looks_like_image_generation_model(model):
|
if model:
|
||||||
continue
|
if model not in configured or not looks_like_image_generation_model(model):
|
||||||
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target:
|
continue
|
||||||
|
else:
|
||||||
|
if not any(looks_like_image_generation_model(v) for v in configured):
|
||||||
|
continue
|
||||||
|
ep_id = str(getattr(ep, "id", "") or "")
|
||||||
|
ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", ""))
|
||||||
|
if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target):
|
||||||
if owner and getattr(ep, "owner", None) == owner:
|
if owner and getattr(ep, "owner", None) == owner:
|
||||||
return ep
|
return ep
|
||||||
if fallback is None:
|
if fallback is None:
|
||||||
@@ -712,7 +718,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
|
|||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
|
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
|
||||||
async with httpx.AsyncClient(timeout=60) as c2:
|
async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2:
|
||||||
ir = await c2.get(url)
|
ir = await c2.get(url)
|
||||||
if ir.status_code == 200:
|
if ir.status_code == 200:
|
||||||
return base64.b64encode(ir.content).decode()
|
return base64.b64encode(ir.content).decode()
|
||||||
@@ -779,14 +785,17 @@ async def _forward_seneca_image_op(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise HTTPException(r.status_code if r.status_code in (400, 404, 422) else 502, detail)
|
raise HTTPException(r.status_code if r.status_code in (400, 404, 422) else 502, detail)
|
||||||
result = r.json()
|
try:
|
||||||
|
result = r.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(502, "Image endpoint returned invalid JSON") from exc
|
||||||
raw_b64: Optional[str] = None
|
raw_b64: Optional[str] = None
|
||||||
used_model = ""
|
used_model = ""
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
used_model = str(result.get("model") or "")
|
used_model = str(result.get("model") or "")
|
||||||
data = result.get("data")
|
data = result.get("data")
|
||||||
if isinstance(data, list) and data:
|
if isinstance(data, list) and data:
|
||||||
item = data[0] or {}
|
item = data[0] if isinstance(data[0], dict) else {}
|
||||||
raw_b64 = item.get("b64_json") or None
|
raw_b64 = item.get("b64_json") or None
|
||||||
if not raw_b64 and item.get("url"):
|
if not raw_b64 and item.get("url"):
|
||||||
raw_b64 = await _fetch_result_image_b64(item["url"])
|
raw_b64 = await _fetch_result_image_b64(item["url"])
|
||||||
@@ -1727,7 +1736,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
||||||
# SSRF hardening: validate a client-supplied endpoint before any
|
# SSRF hardening: validate a client-supplied endpoint before any
|
||||||
# outbound request (mirrors routes/embedding_routes.py).
|
# outbound request (mirrors routes/embedding_routes.py).
|
||||||
if requested_base:
|
if requested_base and "://" in requested_base:
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
ok, reason = check_outbound_url(
|
ok, reason = check_outbound_url(
|
||||||
requested_base,
|
requested_base,
|
||||||
@@ -1842,8 +1851,8 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
headers = {"Authorization": f"Bearer {api_key}"}
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
try:
|
try:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"inpaint_proxy provider edit -> prompt=%r model=%r size=%r base=%s",
|
"inpaint_proxy provider edit -> model=%r size=%r base=%s",
|
||||||
body.get("prompt", ""), oa_model, size, base,
|
oa_model, size, base,
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient(timeout=300) as client:
|
async with httpx.AsyncClient(timeout=300) as client:
|
||||||
if is_openrouter:
|
if is_openrouter:
|
||||||
@@ -1978,9 +1987,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
finally:
|
finally:
|
||||||
_restore_ms[0] += (time.perf_counter() - _rs) * 1000.0
|
_restore_ms[0] += (time.perf_counter() - _rs) * 1000.0
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"inpaint_proxy self-hosted /images/edits -> prompt=%r model=%r "
|
"inpaint_proxy self-hosted /images/edits -> model=%r "
|
||||||
"size=%r steps=%r endpoint=%s",
|
"size=%r steps=%r endpoint=%s",
|
||||||
data.get("prompt"), data.get("model"), data.get("size"),
|
data.get("model"), data.get("size"),
|
||||||
data.get("steps"), base,
|
data.get("steps"), base,
|
||||||
)
|
)
|
||||||
_prep_ms = (time.perf_counter() - _t0) * 1000.0
|
_prep_ms = (time.perf_counter() - _t0) * 1000.0
|
||||||
@@ -2073,7 +2082,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
runs off the event loop (large images).
|
runs off the event loop (large images).
|
||||||
"""
|
"""
|
||||||
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
||||||
if requested_base:
|
if requested_base and "://" in requested_base:
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
ok, reason = check_outbound_url(
|
ok, reason = check_outbound_url(
|
||||||
requested_base,
|
requested_base,
|
||||||
@@ -2166,26 +2175,38 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
try:
|
try:
|
||||||
ep = None
|
ep = None
|
||||||
if endpoint_id:
|
if endpoint_id:
|
||||||
ep = owner_filter(
|
ep = _visible_image_endpoint_for_base(db, endpoint_id, user)
|
||||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id),
|
if ep is None:
|
||||||
ModelEndpoint, user,
|
return {"available": False, "reason": "endpoint not found or disabled"}
|
||||||
).first()
|
elif requested_base:
|
||||||
if ep is None and requested_base:
|
|
||||||
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
||||||
if ep is None:
|
if ep is None:
|
||||||
|
return {"available": False, "reason": "endpoint not found or disabled"}
|
||||||
|
else:
|
||||||
ep = _first_visible_image_endpoint(db, user)
|
ep = _first_visible_image_endpoint(db, user)
|
||||||
if ep is None:
|
if ep is None:
|
||||||
return {"available": False, "reason": "no image endpoint configured"}
|
return {"available": False, "reason": "no image endpoint configured"}
|
||||||
base = ep.base_url.rstrip("/")
|
base = ep.base_url.rstrip("/")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
from src.url_safety import check_outbound_url
|
||||||
|
block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true"
|
||||||
|
ok, reason = check_outbound_url(base, block_private=block_private)
|
||||||
|
if not ok:
|
||||||
|
# CodeQL #1203: Never expose internal addresses, resolver errors or raw
|
||||||
|
# exception details to clients or logs. Use a stable diagnostic message
|
||||||
|
# without interpolating untrusted exception content or destination strings.
|
||||||
|
logger.warning("edit-progress endpoint rejected: outbound URL policy check failed")
|
||||||
|
return {"available": False, "reason": "endpoint URL rejected"}
|
||||||
|
|
||||||
if not base.endswith("/v1"):
|
if not base.endswith("/v1"):
|
||||||
base += "/v1"
|
base += "/v1"
|
||||||
# Dynamic path segment (validated token above), so not routed through
|
# Dynamic path segment (validated token above), so not routed through
|
||||||
# _join_checked_gallery_endpoint's exact-match whitelist.
|
# _join_checked_gallery_endpoint's exact-match whitelist.
|
||||||
url = f"{base}/images/progress/{request_id}"
|
url = f"{base}/images/progress/{request_id}"
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
|
||||||
r = await client.get(url)
|
r = await client.get(url)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
@@ -2223,7 +2244,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# below, so validate it first (mirrors routes/embedding_routes.py).
|
# below, so validate it first (mirrors routes/embedding_routes.py).
|
||||||
# Local-first means loopback/LAN is allowed by default; the cloud
|
# Local-first means loopback/LAN is allowed by default; the cloud
|
||||||
# metadata range and non-HTTP(S) schemes are always rejected.
|
# metadata range and non-HTTP(S) schemes are always rejected.
|
||||||
if requested_base:
|
if requested_base and "://" in requested_base:
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
ok, reason = check_outbound_url(
|
ok, reason = check_outbound_url(
|
||||||
requested_base,
|
requested_base,
|
||||||
@@ -2250,7 +2271,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# so the outbound URL never depends directly on request-body input.
|
# so the outbound URL never depends directly on request-body input.
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
ep = _visible_image_endpoint_for_base(db, requested_base, user, model)
|
||||||
if not ep:
|
if not ep:
|
||||||
raise HTTPException(403, "Choose a registered image endpoint")
|
raise HTTPException(403, "Choose a registered image endpoint")
|
||||||
base = ep.base_url.rstrip("/")
|
base = ep.base_url.rstrip("/")
|
||||||
@@ -2412,11 +2433,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
image_b64 = body.get("image")
|
image_b64 = body.get("image")
|
||||||
amount = body.get("amount", 50) / 100.0
|
amount = body.get("amount", 50) / 100.0
|
||||||
|
|
||||||
from PIL import Image, ImageFilter
|
from PIL import ImageFilter
|
||||||
import base64, io
|
import base64, io
|
||||||
|
|
||||||
img_bytes = _b64_bytes(image_b64)
|
img = _b64_to_pil_image(image_b64, mode="RGB")
|
||||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
|
||||||
|
|
||||||
# Unsharp mask: radius=2, percent=amount*200, threshold=3
|
# Unsharp mask: radius=2, percent=amount*200, threshold=3
|
||||||
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3))
|
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3))
|
||||||
@@ -2451,8 +2471,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
raise HTTPException(500, "Server missing a required dependency")
|
raise HTTPException(500, "Server missing a required dependency")
|
||||||
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
|
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
|
||||||
img_bytes = _b64_bytes(image_b64)
|
src = _b64_to_pil_image(image_b64, mode="RGB")
|
||||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
|
||||||
try:
|
try:
|
||||||
patch_realesrgan_torchvision_compat()
|
patch_realesrgan_torchvision_compat()
|
||||||
from realesrgan import RealESRGANer
|
from realesrgan import RealESRGANer
|
||||||
@@ -2504,8 +2523,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise HTTPException(500, "Server missing a required dependency")
|
raise HTTPException(500, "Server missing a required dependency")
|
||||||
img_bytes = _b64_bytes(image_b64)
|
src = _b64_to_pil_image(image_b64, mode="RGB")
|
||||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
|
||||||
try:
|
try:
|
||||||
patch_realesrgan_torchvision_compat()
|
patch_realesrgan_torchvision_compat()
|
||||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||||
@@ -2757,8 +2775,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
import base64, io
|
import base64, io
|
||||||
|
|
||||||
img_bytes = _b64_bytes(image_b64)
|
img = _b64_to_pil_image(image_b64)
|
||||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
|
|
||||||
W, H = img.size
|
W, H = img.size
|
||||||
|
|
||||||
hint = None
|
hint = None
|
||||||
@@ -2847,8 +2864,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
from PIL import Image, ImageFilter, ImageEnhance
|
from PIL import Image, ImageFilter, ImageEnhance
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
img_bytes = _b64_bytes(image_b64)
|
img = _b64_to_pil_image(image_b64, mode="RGB")
|
||||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
|
||||||
|
|
||||||
# Try GFPGAN first (AI face restoration)
|
# Try GFPGAN first (AI face restoration)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import html
|
import html
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
@@ -12,7 +13,7 @@ import logging
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from core.database import McpServer, SessionLocal
|
from core.database import McpServer, SessionLocal
|
||||||
from core.middleware import require_admin
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, require_admin
|
||||||
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
||||||
from src.mcp_manager import McpManager
|
from src.mcp_manager import McpManager
|
||||||
|
|
||||||
@@ -424,6 +425,11 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
|||||||
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
||||||
"""
|
"""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||||
|
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
|
||||||
|
# Same boundary as POST /api/tools: an agent turn must not be able
|
||||||
|
# to re-enable MCP tools an admin switched off.
|
||||||
|
raise HTTPException(403, "Change MCP tool policy in Settings > Integrations, not from chat")
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import json
|
|||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import socket
|
import socket
|
||||||
|
import secrets
|
||||||
import time as _time
|
import time as _time
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
@@ -3091,6 +3092,12 @@ def setup_model_routes(model_discovery):
|
|||||||
def update_tools(body: ToolsUpdate, request: Request):
|
def update_tools(body: ToolsUpdate, request: Request):
|
||||||
"""Update which tools are disabled."""
|
"""Update which tools are disabled."""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||||
|
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||||
|
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
|
||||||
|
# Owner impersonation can carry an admin username, so check the
|
||||||
|
# authenticated transport rather than request.state.current_user.
|
||||||
|
raise HTTPException(403, "Change tool policy in Settings > Agent Tools, not from chat")
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
settings["disabled_tools"] = body.disabled
|
settings["disabled_tools"] = body.disabled
|
||||||
_save_settings(settings)
|
_save_settings(settings)
|
||||||
|
|||||||
@@ -728,32 +728,35 @@ def setup_note_routes(task_scheduler=None, upload_handler=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:
|
# Omitted fields preserve their values; explicit null clears the
|
||||||
|
# nullable fields used by the editor's removal controls.
|
||||||
|
supplied = body.model_fields_set
|
||||||
|
if "content" in supplied:
|
||||||
note.content = body.content
|
note.content = body.content
|
||||||
if body.items is not None:
|
if "items" in supplied:
|
||||||
note.items = json.dumps(body.items)
|
note.items = json.dumps(body.items) if body.items is not None else None
|
||||||
flag_modified(note, "items")
|
flag_modified(note, "items")
|
||||||
if body.note_type is not None:
|
if body.note_type is not None:
|
||||||
note.note_type = body.note_type
|
note.note_type = body.note_type
|
||||||
if body.color is not None:
|
if "color" in supplied:
|
||||||
note.color = body.color
|
note.color = body.color
|
||||||
if body.label is not None:
|
if "label" in supplied:
|
||||||
note.label = body.label
|
note.label = body.label
|
||||||
if body.pinned is not None:
|
if body.pinned is not None:
|
||||||
note.pinned = body.pinned
|
note.pinned = body.pinned
|
||||||
if body.archived is not None:
|
if body.archived is not None:
|
||||||
note.archived = body.archived
|
note.archived = body.archived
|
||||||
if body.due_date is not None:
|
if "due_date" in supplied:
|
||||||
note.due_date = body.due_date
|
note.due_date = body.due_date
|
||||||
if body.image_url is not None:
|
if "image_url" in supplied:
|
||||||
note.image_url = body.image_url
|
note.image_url = body.image_url
|
||||||
if body.gallery_id is not None:
|
if "gallery_id" in supplied:
|
||||||
note.gallery_id = body.gallery_id
|
note.gallery_id = body.gallery_id
|
||||||
if body.repeat is not None:
|
if body.repeat is not None:
|
||||||
note.repeat = body.repeat
|
note.repeat = body.repeat
|
||||||
if body.sort_order is not None:
|
if body.sort_order is not None:
|
||||||
note.sort_order = body.sort_order
|
note.sort_order = body.sort_order
|
||||||
if body.agent_session_id is not None:
|
if "agent_session_id" in supplied:
|
||||||
note.agent_session_id = body.agent_session_id
|
note.agent_session_id = body.agent_session_id
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -290,14 +290,9 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
# (most sessions have none), not an error. Return HTTP 200 with an
|
# (most sessions have none), not an error. Return HTTP 200 with an
|
||||||
# explicit empty status so loading a session does not log a spurious
|
# explicit empty status so loading a session does not log a spurious
|
||||||
# 404; every client treats a non-"running" status as idle anyway.
|
# 404; every client treats a non-"running" status as idle anyway.
|
||||||
# A session whose research exists but belongs to ANOTHER user stays a
|
# Research owned by ANOTHER user answers identically, so the response
|
||||||
# 404, so we do not leak the existence of someone else's research.
|
# does not reveal whether that research exists.
|
||||||
if not _owns_in_memory(session_id, user):
|
if not _owns_in_memory(session_id, user):
|
||||||
exists = session_id in getattr(research_handler, "_active_tasks", {})
|
|
||||||
if not exists:
|
|
||||||
exists = _find_research_path(session_id) is not None
|
|
||||||
if exists:
|
|
||||||
raise HTTPException(404, "No research found for this session")
|
|
||||||
return {"status": "none"}
|
return {"status": "none"}
|
||||||
status = research_handler.get_status(session_id)
|
status = research_handler.get_status(session_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
|
|||||||
@@ -1315,7 +1315,7 @@ def setup_session_routes(
|
|||||||
def auto_sort_sessions(request: Request, skip_llm: bool = False):
|
def auto_sort_sessions(request: Request, skip_llm: bool = False):
|
||||||
"""Use AI to categorize all sessions into folders.
|
"""Use AI to categorize all sessions into folders.
|
||||||
|
|
||||||
Phase 1 deletes empty/throwaway sessions and Phase 2 asks the LLM
|
Phase 1 deletes empty/Incognito sessions and Phase 2 asks the LLM
|
||||||
to assign folders. When `skip_llm=true` the endpoint returns
|
to assign folders. When `skip_llm=true` the endpoint returns
|
||||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||||
users can clean junk without spending tokens.
|
users can clean junk without spending tokens.
|
||||||
@@ -1326,21 +1326,11 @@ def setup_session_routes(
|
|||||||
single_user_mode = not user and _auth_disabled()
|
single_user_mode = not user and _auth_disabled()
|
||||||
user_sessions = session_manager.get_sessions_for_user(user)
|
user_sessions = session_manager.get_sessions_for_user(user)
|
||||||
|
|
||||||
# Delete empty and throwaway sessions before sorting
|
# Saved conversations are never disposable based on their title or prompt.
|
||||||
from core.database import ChatMessage as DbMsg
|
from core.database import ChatMessage as DbMsg
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
deleted_empty = 0
|
deleted_empty = 0
|
||||||
deleted_throwaway = 0
|
deleted_throwaway = 0
|
||||||
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
|
|
||||||
_THROWAWAY_NAMES = {
|
|
||||||
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
|
|
||||||
"yo", "sup", "hola", "hii", "hiii", "heyo",
|
|
||||||
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
|
|
||||||
"new chat", "delete", "remove", "junk", "trash", "xxx",
|
|
||||||
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
|
|
||||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
|
||||||
}
|
|
||||||
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
|
|
||||||
try:
|
try:
|
||||||
rows_q = db.query(DbSession).filter(DbSession.archived == False)
|
rows_q = db.query(DbSession).filter(DbSession.archived == False)
|
||||||
if user:
|
if user:
|
||||||
@@ -1349,15 +1339,11 @@ def setup_session_routes(
|
|||||||
rows_q = rows_q.filter(DbSession.owner == user)
|
rows_q = rows_q.filter(DbSession.owner == user)
|
||||||
rows = rows_q.limit(2000).all()
|
rows = rows_q.limit(2000).all()
|
||||||
folder_map = {r.id: r.folder for r in rows}
|
folder_map = {r.id: r.folder for r in rows}
|
||||||
# Precompute per-session message counts in TWO aggregate queries
|
# Precompute per-session message counts in one aggregate query
|
||||||
# instead of 1–3 queries PER session — with many chats the per-row
|
# instead of 1–3 queries PER session — with many chats the per-row
|
||||||
# loop was doing thousands of round-trips and blowing the timeout.
|
# loop was doing thousands of round-trips and blowing the timeout.
|
||||||
from sqlalchemy import func as _sa_func
|
from sqlalchemy import func as _sa_func
|
||||||
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
|
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
|
||||||
_asst_counts = dict(
|
|
||||||
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
|
|
||||||
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
|
|
||||||
)
|
|
||||||
cleanup_now = utcnow_naive()
|
cleanup_now = utcnow_naive()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
# Never delete important sessions
|
# Never delete important sessions
|
||||||
@@ -1367,9 +1353,10 @@ def setup_session_routes(
|
|||||||
if (row.name or "").strip() == "Incognito":
|
if (row.name or "").strip() == "Incognito":
|
||||||
should_delete = True
|
should_delete = True
|
||||||
deleted_throwaway += 1
|
deleted_throwaway += 1
|
||||||
db.delete(row)
|
|
||||||
if hasattr(session_manager, 'delete_session'):
|
if hasattr(session_manager, 'delete_session'):
|
||||||
session_manager.delete_session(row.id)
|
session_manager.delete_session(row.id)
|
||||||
|
else:
|
||||||
|
db.delete(row)
|
||||||
continue
|
continue
|
||||||
if is_session_recently_active(row, now=cleanup_now):
|
if is_session_recently_active(row, now=cleanup_now):
|
||||||
continue
|
continue
|
||||||
@@ -1378,31 +1365,11 @@ def setup_session_routes(
|
|||||||
if msg_count == 0:
|
if msg_count == 0:
|
||||||
should_delete = True
|
should_delete = True
|
||||||
deleted_empty += 1
|
deleted_empty += 1
|
||||||
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
|
|
||||||
name = (row.name or "").strip().lower()
|
|
||||||
# Check first user message content (AI renames sessions, so
|
|
||||||
# "hi" becomes "Casual Greeting Exchange" — name alone won't match)
|
|
||||||
first_msg = db.query(DbMsg.content).filter(
|
|
||||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
|
||||||
).order_by(DbMsg.timestamp).first()
|
|
||||||
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
|
|
||||||
# Count assistant messages — if user sent something but AI never replied, it's dead
|
|
||||||
assistant_count = _asst_counts.get(row.id, 0)
|
|
||||||
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
|
|
||||||
should_delete = True
|
|
||||||
deleted_throwaway += 1
|
|
||||||
# Single user message with no AI response = dead session
|
|
||||||
elif msg_count == 1 and assistant_count == 0:
|
|
||||||
should_delete = True
|
|
||||||
deleted_throwaway += 1
|
|
||||||
# Short phrase (1-3 words) with no real AI conversation (<=2 msgs)
|
|
||||||
elif msg_count <= 2 and first_text and len(first_text.split()) <= 3 and len(first_text) <= 40:
|
|
||||||
should_delete = True
|
|
||||||
deleted_throwaway += 1
|
|
||||||
if should_delete:
|
if should_delete:
|
||||||
db.delete(row)
|
|
||||||
if hasattr(session_manager, 'delete_session'):
|
if hasattr(session_manager, 'delete_session'):
|
||||||
session_manager.delete_session(row.id)
|
session_manager.delete_session(row.id)
|
||||||
|
else:
|
||||||
|
db.delete(row)
|
||||||
if deleted_empty or deleted_throwaway:
|
if deleted_empty or deleted_throwaway:
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
|
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from src.task_action_policy import (
|
|||||||
is_admin_only_task_action,
|
is_admin_only_task_action,
|
||||||
owner_has_admin_task_privileges,
|
owner_has_admin_task_privileges,
|
||||||
)
|
)
|
||||||
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
|
from src.task_scheduler import compute_next_run, is_valid_cron, HOUSEKEEPING_DEFAULTS
|
||||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -475,10 +475,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
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:
|
||||||
raise HTTPException(400, "Cron expression is required for cron schedule")
|
raise HTTPException(400, "Cron expression is required for cron schedule")
|
||||||
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
|
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
|
||||||
try:
|
if not is_valid_cron(req.cron_expression):
|
||||||
from croniter import croniter
|
|
||||||
croniter(req.cron_expression)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(400, "Invalid cron expression")
|
raise HTTPException(400, "Invalid cron expression")
|
||||||
if req.trigger_type == "event" and not req.trigger_event:
|
if req.trigger_type == "event" and not req.trigger_event:
|
||||||
raise HTTPException(400, "Event name is required for event-triggered tasks")
|
raise HTTPException(400, "Event name is required for event-triggered tasks")
|
||||||
@@ -788,10 +785,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
task.character_id = req.character_id or None
|
task.character_id = req.character_id or None
|
||||||
if req.cron_expression is not None:
|
if req.cron_expression is not None:
|
||||||
if req.cron_expression:
|
if req.cron_expression:
|
||||||
try:
|
if not is_valid_cron(req.cron_expression):
|
||||||
from croniter import croniter
|
|
||||||
croniter(req.cron_expression)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(400, "Invalid cron expression")
|
raise HTTPException(400, "Invalid cron expression")
|
||||||
task.cron_expression = req.cron_expression or None
|
task.cron_expression = req.cron_expression or None
|
||||||
|
|
||||||
|
|||||||
+92
-46
@@ -25,11 +25,12 @@ from src.auth_helpers import effective_user, is_delegated_credential
|
|||||||
from src.owner_identity import auth_disabled, is_request_sentinel_owner
|
from src.owner_identity import auth_disabled, is_request_sentinel_owner
|
||||||
from src.attachment_refs import attachment_refs_from_metadata
|
from src.attachment_refs import attachment_refs_from_metadata
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
from src.path_confinement import is_inside
|
from src.path_confinement import PathEscape, canonical_root, confine, is_inside
|
||||||
from src.upload_handler import (
|
from src.upload_handler import (
|
||||||
UploadCleanupSafetyError,
|
UploadCleanupSafetyError,
|
||||||
count_recent_uploads,
|
count_recent_uploads,
|
||||||
extract_upload_ids,
|
extract_upload_ids,
|
||||||
|
secure_filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -153,30 +154,47 @@ def setup_upload_routes(upload_handler):
|
|||||||
|
|
||||||
def _upload_root() -> str:
|
def _upload_root() -> str:
|
||||||
from src.constants import UPLOAD_DIR
|
from src.constants import UPLOAD_DIR
|
||||||
return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
|
return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
|
||||||
|
|
||||||
def _path_inside_upload_dir(path: str) -> bool:
|
def _path_inside_upload_dir(path: str) -> bool:
|
||||||
return is_inside(_upload_root(), path)
|
return is_inside(_upload_root(), path)
|
||||||
|
|
||||||
def _resolve_upload_path(file_id: str) -> str:
|
def _resolve_upload_path(file_id: str) -> str:
|
||||||
from src.constants import UPLOAD_DIR
|
safe_id = secure_filename(os.path.basename(file_id))
|
||||||
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
|
if not safe_id:
|
||||||
direct = os.path.join(upload_root, file_id)
|
raise HTTPException(400, "Invalid file ID")
|
||||||
|
root = _upload_root()
|
||||||
|
|
||||||
|
direct = os.path.normpath(os.path.join(root, safe_id))
|
||||||
if os.path.lexists(direct):
|
if os.path.lexists(direct):
|
||||||
if not _path_inside_upload_dir(direct):
|
try:
|
||||||
|
canonical_direct = confine(root, direct, allow_root=False)
|
||||||
|
except (PathEscape, ValueError):
|
||||||
raise HTTPException(403, "Access denied")
|
raise HTTPException(403, "Access denied")
|
||||||
if os.path.isfile(direct):
|
if os.path.isfile(canonical_direct):
|
||||||
return direct
|
return canonical_direct
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
for root, _dirs, files in os.walk(upload_root, followlinks=False):
|
info = _load_upload_info(safe_id)
|
||||||
if file_id not in files:
|
stored_path = (info or {}).get("path")
|
||||||
continue
|
if stored_path:
|
||||||
path = os.path.join(root, file_id)
|
try:
|
||||||
if not _path_inside_upload_dir(path):
|
canonical_stored = confine(root, stored_path, allow_root=False)
|
||||||
|
if os.path.isfile(canonical_stored):
|
||||||
|
return canonical_stored
|
||||||
|
except (PathEscape, ValueError):
|
||||||
raise HTTPException(403, "Access denied")
|
raise HTTPException(403, "Access denied")
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
for walk_root, _dirs, files in os.walk(root, followlinks=False):
|
||||||
|
if safe_id not in files:
|
||||||
|
continue
|
||||||
|
cand = os.path.normpath(os.path.join(walk_root, safe_id))
|
||||||
|
try:
|
||||||
|
canonical_cand = confine(root, cand, allow_root=False)
|
||||||
|
except (PathEscape, ValueError):
|
||||||
|
raise HTTPException(403, "Access denied")
|
||||||
|
if os.path.isfile(canonical_cand):
|
||||||
|
return canonical_cand
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
@@ -435,52 +453,79 @@ def setup_upload_routes(upload_handler):
|
|||||||
logger.error(f"Failed to get upload stats: {e}")
|
logger.error(f"Failed to get upload stats: {e}")
|
||||||
raise HTTPException(500, "Failed to get upload statistics")
|
raise HTTPException(500, "Failed to get upload statistics")
|
||||||
|
|
||||||
@router.get("/{file_id}")
|
@router.api_route("/{file_id}", methods=["GET", "HEAD"])
|
||||||
async def download_file(request: Request, file_id: str, thumb: int = 0):
|
async def download_file(request: Request, file_id: str, thumb: int = 0):
|
||||||
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
|
"""Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns
|
||||||
JPEG thumbnail for images (used by chat attachment previews) so the
|
a small cached JPEG thumbnail for images (used by chat attachment previews)
|
||||||
client isn't downloading the full-resolution photo just to show it tiny."""
|
so the client isn't downloading the full-resolution photo just to show it tiny."""
|
||||||
if not upload_handler.validate_upload_id(file_id):
|
if not upload_handler.validate_upload_id(file_id):
|
||||||
raise HTTPException(400, "Invalid file ID")
|
raise HTTPException(400, "Invalid file ID")
|
||||||
|
safe_file_id = secure_filename(os.path.basename(file_id))
|
||||||
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 = safe_file_id
|
||||||
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
|
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
|
||||||
# back to the .bak sibling, then to {}), so a truncated DB degrades to
|
# back to the .bak sibling, then to {}), so a truncated DB degrades to
|
||||||
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
|
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
|
||||||
db = upload_handler._load_upload_index()
|
db = upload_handler._load_upload_index()
|
||||||
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") == safe_file_id), None)
|
||||||
if info:
|
if info:
|
||||||
original_name = info.get("name", file_id)
|
original_name = info.get("name", safe_file_id)
|
||||||
file_owner = info.get("owner") if info else None
|
file_owner = info.get("owner") if info else None
|
||||||
_verify_upload_access(request, file_owner)
|
_verify_upload_access(request, file_owner)
|
||||||
path = _resolve_upload_path(file_id)
|
upload_root = _upload_root()
|
||||||
|
path = _resolve_upload_path(safe_file_id)
|
||||||
|
try:
|
||||||
|
path = confine(upload_root, path, allow_root=False)
|
||||||
|
except (PathEscape, ValueError):
|
||||||
|
raise HTTPException(403, "Access denied")
|
||||||
|
if not path.startswith(upload_root + os.sep) or not _path_inside_upload_dir(path):
|
||||||
|
raise HTTPException(403, "Access denied")
|
||||||
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
|
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
# Downscaled thumbnail for image previews — generated once and cached.
|
# Downscaled thumbnail for image previews — generated once and cached.
|
||||||
|
# Skip generation during HEAD probe to avoid loading/processing whole image.
|
||||||
if thumb and mime.startswith("image/"):
|
if thumb and mime.startswith("image/"):
|
||||||
|
thumb_dir = os.path.normpath(os.path.join(upload_root, ".thumbs"))
|
||||||
|
thumb_path = os.path.normpath(os.path.join(thumb_dir, safe_file_id + ".jpg"))
|
||||||
try:
|
try:
|
||||||
from PIL import Image, ImageOps
|
thumb_path = confine(upload_root, thumb_path, allow_root=False)
|
||||||
thumb_dir = os.path.join(_upload_root(), ".thumbs")
|
except (PathEscape, ValueError):
|
||||||
os.makedirs(thumb_dir, exist_ok=True)
|
raise HTTPException(403, "Access denied")
|
||||||
thumb_path = os.path.join(thumb_dir, file_id + ".jpg")
|
if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path):
|
||||||
if (not os.path.exists(thumb_path)
|
raise HTTPException(403, "Access denied")
|
||||||
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
|
if request.method == "HEAD":
|
||||||
im = Image.open(path)
|
if (os.path.isfile(thumb_path)
|
||||||
# iPhone / camera JPEGs encode rotation in EXIF rather than
|
and os.path.getmtime(thumb_path) >= os.path.getmtime(path)):
|
||||||
# the pixel data. Browsers honour that on the original via
|
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
||||||
# image-orientation:from-image, but PIL strips EXIF when it
|
# GET may generate a JPEG or fall back to the original on failure.
|
||||||
# saves the JPEG thumb, leaving the pixels sideways. Bake
|
# Neither representation is known without decoding the image. HEAD
|
||||||
# the rotation into the pixels before thumbnailing.
|
# must not advertise the original/stale file's headers as a thumb.
|
||||||
im = ImageOps.exif_transpose(im)
|
from fastapi.responses import Response
|
||||||
im.thumbnail((320, 320))
|
response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS)
|
||||||
if im.mode not in ("RGB", "L"):
|
del response.headers["content-length"]
|
||||||
im = im.convert("RGB")
|
return response
|
||||||
im.save(thumb_path, "JPEG", quality=80)
|
else:
|
||||||
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
try:
|
||||||
except Exception as e:
|
from PIL import Image, ImageOps
|
||||||
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
|
os.makedirs(thumb_dir, exist_ok=True)
|
||||||
# Fall through to the full image.
|
if (not os.path.exists(thumb_path)
|
||||||
|
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
|
||||||
|
im = Image.open(path)
|
||||||
|
# iPhone / camera JPEGs encode rotation in EXIF rather than
|
||||||
|
# the pixel data. Browsers honour that on the original via
|
||||||
|
# image-orientation:from-image, but PIL strips EXIF when it
|
||||||
|
# saves the JPEG thumb, leaving the pixels sideways. Bake
|
||||||
|
# the rotation into the pixels before thumbnailing.
|
||||||
|
im = ImageOps.exif_transpose(im)
|
||||||
|
im.thumbnail((320, 320))
|
||||||
|
if im.mode not in ("RGB", "L"):
|
||||||
|
im = im.convert("RGB")
|
||||||
|
im.save(thumb_path, "JPEG", quality=80)
|
||||||
|
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
|
||||||
|
# Fall through to the full image.
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path,
|
path,
|
||||||
media_type=mime,
|
media_type=mime,
|
||||||
@@ -496,9 +541,10 @@ def setup_upload_routes(upload_handler):
|
|||||||
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||||
|
|
||||||
def _vision_cache_path(file_id: str) -> str:
|
def _vision_cache_path(file_id: str) -> str:
|
||||||
cache_dir = os.path.join(_upload_root(), ".vision")
|
safe_id = secure_filename(os.path.basename(file_id))
|
||||||
|
cache_dir = os.path.normpath(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.normpath(os.path.join(cache_dir, safe_id + ".txt"))
|
||||||
|
|
||||||
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
|
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."""
|
"""Copy upload OCR/vision text onto the promoted gallery image row."""
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Fail image builds that select an unfixed Debian native Brotli decoder.
|
||||||
|
|
||||||
|
Debian Node links libbrotlidec rather than bundling upstream Node's decoder.
|
||||||
|
Use Debian's backport revision, not BrotliDecoderVersion() or Python wheel
|
||||||
|
metadata, to establish the package floor for CVE-2026-111497:
|
||||||
|
https://security-tracker.debian.org/tracker/CVE-2026-111497
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ctypes
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
DEBIAN_FIXED = {"12": "1.0.9-2+deb12u1", "13": "1.1.0-2+deb13u1"}
|
||||||
|
DECODER_SONAME = "libbrotlidec.so.1"
|
||||||
|
|
||||||
|
|
||||||
|
def command(*args):
|
||||||
|
return subprocess.check_output(args, text=True).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def check_package():
|
||||||
|
release = platform.freedesktop_os_release()
|
||||||
|
floor = DEBIAN_FIXED.get(release.get("VERSION_ID"))
|
||||||
|
if release.get("ID") != "debian" or floor is None:
|
||||||
|
raise ValueError("Brotli package floor is not defined for this distribution")
|
||||||
|
record = command(
|
||||||
|
"dpkg-query", "-W", "-f=${Status}\n${Version}", "libbrotli1"
|
||||||
|
).splitlines()
|
||||||
|
if len(record) != 2 or any(not value.strip() for value in record):
|
||||||
|
raise ValueError("invalid libbrotli1 package record: expected status and version")
|
||||||
|
status, version = record
|
||||||
|
if status != "install ok installed":
|
||||||
|
raise ValueError(f"libbrotli1 is not fully installed (status: {status})")
|
||||||
|
if subprocess.run(
|
||||||
|
["dpkg", "--compare-versions", version, "ge", floor], check=False
|
||||||
|
).returncode != 0:
|
||||||
|
raise ValueError(f"libbrotli1 {version} does not meet Debian's fixed floor {floor}")
|
||||||
|
owned = {
|
||||||
|
Path(line).resolve()
|
||||||
|
for line in command("dpkg-query", "-L", "libbrotli1").splitlines()
|
||||||
|
if Path(line).name == DECODER_SONAME
|
||||||
|
}
|
||||||
|
if not owned:
|
||||||
|
raise ValueError("libbrotli1 has no packaged native decoder")
|
||||||
|
return version, owned
|
||||||
|
|
||||||
|
|
||||||
|
def loaded_path(library):
|
||||||
|
# Inspect the library actually selected by the dynamic loader. An installed
|
||||||
|
# fixed package alone would not catch LD_LIBRARY_PATH or /usr/local shadows.
|
||||||
|
class DlInfo(ctypes.Structure):
|
||||||
|
_fields_ = [("filename", ctypes.c_char_p), ("base", ctypes.c_void_p),
|
||||||
|
("symbol", ctypes.c_char_p), ("address", ctypes.c_void_p)]
|
||||||
|
|
||||||
|
dladdr = ctypes.CDLL(None).dladdr
|
||||||
|
dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(DlInfo)]
|
||||||
|
dladdr.restype = ctypes.c_int
|
||||||
|
info = DlInfo()
|
||||||
|
if not dladdr(ctypes.cast(library.BrotliDecoderDecompress, ctypes.c_void_p), ctypes.byref(info)):
|
||||||
|
raise ValueError("cannot identify the loaded Brotli decoder")
|
||||||
|
return Path(info.filename.decode()).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def check_decoder(library):
|
||||||
|
# The fix adds error -17. Older decoders return INVALID here, including
|
||||||
|
# upstream 1.1.0 and 1.2.0. Fixed Debian 12 and 13 backports expose the same
|
||||||
|
# marker with different error-string prefixes.
|
||||||
|
# No malformed attacker-controlled stream is needed for this build check.
|
||||||
|
library.BrotliDecoderErrorString.argtypes = [ctypes.c_int]
|
||||||
|
library.BrotliDecoderErrorString.restype = ctypes.c_char_p
|
||||||
|
if library.BrotliDecoderErrorString(-17) not in (b"BLOCK_SWITCH", b"_ERROR_FORMAT_BLOCK_SWITCH"):
|
||||||
|
raise ValueError("native decoder lacks the CVE-2026-111497 BLOCK_SWITCH fix marker")
|
||||||
|
|
||||||
|
library.BrotliDecoderDecompress.argtypes = [
|
||||||
|
ctypes.c_size_t, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p
|
||||||
|
]
|
||||||
|
library.BrotliDecoderDecompress.restype = ctypes.c_int
|
||||||
|
# A small, valid uncompressed Brotli stream must still decode correctly.
|
||||||
|
encoded = bytes.fromhex("0b058068656c6c6f20776f726c6403")
|
||||||
|
output = ctypes.create_string_buffer(11)
|
||||||
|
size = ctypes.c_size_t(len(output))
|
||||||
|
result = library.BrotliDecoderDecompress(len(encoded), encoded, ctypes.byref(size), output)
|
||||||
|
if result != 1 or size.value != 11 or output.raw != b"hello world":
|
||||||
|
raise ValueError("native Brotli decoder failed the valid-stream control")
|
||||||
|
|
||||||
|
|
||||||
|
def check_node(node, decoder):
|
||||||
|
# ldd inspects the distro executable and its libnode dependency without
|
||||||
|
# executing JavaScript or relying on process.versions.brotli.
|
||||||
|
linked = command("ldd", str(node))
|
||||||
|
match = re.search(r"libbrotlidec\.so\.1\s+=>\s+(/\S+)", linked)
|
||||||
|
if not match or Path(match[1]).resolve() != decoder:
|
||||||
|
raise ValueError(f"{node} does not select the checked Debian Brotli decoder")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--node", type=Path, help="also verify this distro Node executable's decoder")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
version, owned = check_package()
|
||||||
|
library = ctypes.CDLL(DECODER_SONAME)
|
||||||
|
decoder = loaded_path(library)
|
||||||
|
if decoder not in owned:
|
||||||
|
raise ValueError(f"selected decoder is outside libbrotli1: {decoder}")
|
||||||
|
check_decoder(library)
|
||||||
|
if args.node:
|
||||||
|
check_node(args.node, decoder)
|
||||||
|
except (ValueError, OSError, AttributeError, subprocess.CalledProcessError) as exc:
|
||||||
|
print(f"Brotli acceptance failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"Brotli accepted: Debian libbrotli1 {version}, decoder {decoder}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -951,14 +951,12 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
|
|||||||
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
|
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
|
||||||
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
|
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
|
||||||
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
|
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
|
||||||
"ODYSSEUS_SPEC_DRIVER_TOPK": ("Agent loop and tool execution", INTERNAL, "Number of router-selected tool candidates exposed in a driver round."),
|
|
||||||
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
|
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
|
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
|
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
|
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
|
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
|
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
|
||||||
"ODYSSEUS_SPEC_SUBSET_OF_BASE": ("Agent loop and tool execution", INTERNAL, "Limit Spec tool selections to the existing request inventory by default."),
|
|
||||||
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
|
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
|
||||||
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
|
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
|
||||||
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
|
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ that are awkward at the shell). Filters images by tag, prompt, favorite,
|
|||||||
album, and surfaces EXIF metadata.
|
album, and surfaces EXIF metadata.
|
||||||
|
|
||||||
odysseus-gallery list --limit 20 --favorites
|
odysseus-gallery list --limit 20 --favorites
|
||||||
|
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
|
||||||
odysseus-gallery show IMAGE_ID
|
odysseus-gallery show IMAGE_ID
|
||||||
odysseus-gallery albums | jq -r '.[] | .name'
|
odysseus-gallery albums | jq -r '.[] | .name'
|
||||||
odysseus-gallery search "sunset" # matches prompt + tags
|
odysseus-gallery search "sunset" # matches prompt + tags
|
||||||
@@ -83,7 +84,13 @@ def cmd_list(args):
|
|||||||
fail(f"no album named {args.album!r}")
|
fail(f"no album named {args.album!r}")
|
||||||
q = q.filter(GalleryImage.album_id == al.id)
|
q = q.filter(GalleryImage.album_id == al.id)
|
||||||
if args.tag:
|
if args.tag:
|
||||||
q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
|
for tag in (value.strip() for value in args.tag.split(",")):
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
q = q.filter(
|
||||||
|
GalleryImage.tags.ilike(f"%{tag}%") |
|
||||||
|
GalleryImage.ai_tags.ilike(f"%{tag}%")
|
||||||
|
)
|
||||||
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
|
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
|
||||||
emit([_serialize_image(i) for i in q.all()], args)
|
emit([_serialize_image(i) for i in q.all()], args)
|
||||||
finally:
|
finally:
|
||||||
@@ -159,7 +166,7 @@ def _build_parser():
|
|||||||
pl.add_argument("--limit", type=int, default=50)
|
pl.add_argument("--limit", type=int, default=50)
|
||||||
pl.add_argument("--favorites", action="store_true")
|
pl.add_argument("--favorites", action="store_true")
|
||||||
pl.add_argument("--album", help="filter by album name")
|
pl.add_argument("--album", help="filter by album name")
|
||||||
pl.add_argument("--tag", help="substring match against tags column")
|
pl.add_argument("--tag", help="comma-separated tags; each matches manual or AI tags by substring")
|
||||||
pl.set_defaults(func=cmd_list)
|
pl.set_defaults(func=cmd_list)
|
||||||
|
|
||||||
psh = sub.add_parser("show", parents=[common])
|
psh = sub.add_parser("show", parents=[common])
|
||||||
|
|||||||
+12
-6
@@ -43,7 +43,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Anchor the path so the script works when invoked from anywhere.
|
# Anchor the path so the script works when invoked from anywhere.
|
||||||
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
|
|||||||
emit([], args)
|
emit([], args)
|
||||||
return
|
return
|
||||||
all_uids = data[0].split()
|
all_uids = data[0].split()
|
||||||
# Newest first, limit
|
# Keep a bounded arrival batch; order its decoded rows by Date below.
|
||||||
uids = list(reversed(all_uids))[: args.limit]
|
uids = list(reversed(all_uids))[: args.limit]
|
||||||
out = []
|
out = []
|
||||||
for uid in uids:
|
for uid in uids:
|
||||||
@@ -165,10 +165,15 @@ def cmd_list(args) -> None:
|
|||||||
sender_name, sender_addr = parseaddr(sender)
|
sender_name, sender_addr = parseaddr(sender)
|
||||||
date_raw = msg.get("Date", "")
|
date_raw = msg.get("Date", "")
|
||||||
try:
|
try:
|
||||||
iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
|
parsed_date = parsedate_to_datetime(date_raw) if date_raw else None
|
||||||
|
iso = parsed_date.isoformat() if parsed_date else ""
|
||||||
|
if parsed_date is not None and parsed_date.tzinfo is None:
|
||||||
|
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
|
||||||
|
date_epoch = parsed_date.timestamp() if parsed_date else float("-inf")
|
||||||
except Exception:
|
except Exception:
|
||||||
iso = ""
|
iso = ""
|
||||||
out.append({
|
date_epoch = float("-inf")
|
||||||
|
out.append((date_epoch, {
|
||||||
"uid": uid.decode(),
|
"uid": uid.decode(),
|
||||||
"date": iso,
|
"date": iso,
|
||||||
"from_addr": sender_addr,
|
"from_addr": sender_addr,
|
||||||
@@ -176,10 +181,11 @@ def cmd_list(args) -> None:
|
|||||||
"subject": subject,
|
"subject": subject,
|
||||||
"is_read": "\\Seen" in flags,
|
"is_read": "\\Seen" in flags,
|
||||||
"is_answered": "\\Answered" in flags,
|
"is_answered": "\\Answered" in flags,
|
||||||
})
|
}))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
|
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
|
||||||
emit(out, args)
|
out.sort(key=lambda item: item[0], reverse=True)
|
||||||
|
emit([row for _, row in out], args)
|
||||||
|
|
||||||
|
|
||||||
# ─── read ────────────────────────────────────────────────────────────
|
# ─── read ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sanitize Playwright trace archives to strip sensitive credentials and tokens.
|
||||||
|
|
||||||
|
Structured, format-aware fail-closed security:
|
||||||
|
Replaces naive regex-based replacement with structured JSON / HAR parsing,
|
||||||
|
recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization,
|
||||||
|
safe source code masking, and strict fail-closed purge on corruption or error.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import zipfile
|
||||||
|
from typing import Any, Dict, List, Set
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico')
|
||||||
|
|
||||||
|
SENSITIVE_KEY_PATTERN = re.compile(
|
||||||
|
r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)'
|
||||||
|
)
|
||||||
|
|
||||||
|
SENSITIVE_PARAM_NAMES = frozenset({
|
||||||
|
"token", "session", "auth", "password", "passwd", "key", "secret", "api_key",
|
||||||
|
"apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig",
|
||||||
|
"signature", "client_secret", "credential"
|
||||||
|
})
|
||||||
|
|
||||||
|
SENSITIVE_HEADER_NAMES = frozenset({
|
||||||
|
"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key",
|
||||||
|
"api-key", "x-auth-token", "x-access-token"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_url(url: str) -> str:
|
||||||
|
"""Sanitize query parameters and authority credentials in URLs."""
|
||||||
|
if not isinstance(url, str) or not url.strip():
|
||||||
|
return url
|
||||||
|
try:
|
||||||
|
parts = urllib.parse.urlsplit(url)
|
||||||
|
except Exception:
|
||||||
|
return url
|
||||||
|
|
||||||
|
netloc = parts.netloc
|
||||||
|
if "@" in netloc:
|
||||||
|
userinfo, host = netloc.split("@", 1)
|
||||||
|
if ":" in userinfo:
|
||||||
|
user, _ = userinfo.split(":", 1)
|
||||||
|
netloc = f"{user}:[REDACTED]@{host}"
|
||||||
|
else:
|
||||||
|
netloc = f"[REDACTED]@{host}"
|
||||||
|
|
||||||
|
query = parts.query
|
||||||
|
if query:
|
||||||
|
q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
|
||||||
|
sanitized_pairs = []
|
||||||
|
for k, v in q_pairs:
|
||||||
|
k_lower = k.lower().replace("-", "_")
|
||||||
|
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
|
||||||
|
sanitized_pairs.append((k, "[REDACTED]"))
|
||||||
|
else:
|
||||||
|
sanitized_pairs.append((k, v))
|
||||||
|
query = urllib.parse.urlencode(sanitized_pairs)
|
||||||
|
|
||||||
|
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_body_text(text: str) -> str:
|
||||||
|
"""Sanitize request bodies formatted as JSON or URL-encoded form data."""
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
return text
|
||||||
|
|
||||||
|
# 1. Try JSON
|
||||||
|
trimmed = text.strip()
|
||||||
|
if trimmed.startswith(("{", "[")):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(trimmed)
|
||||||
|
sanitized = sanitize_structured_obj(parsed)
|
||||||
|
return json.dumps(sanitized, ensure_ascii=False)
|
||||||
|
except Exception as exc:
|
||||||
|
if SENSITIVE_KEY_PATTERN.search(trimmed) or any(p in trimmed.lower() for p in SENSITIVE_PARAM_NAMES):
|
||||||
|
raise ValueError(f"Malformed sensitive JSON request body: {exc}") from exc
|
||||||
|
|
||||||
|
# 2. Try URL-encoded form data
|
||||||
|
if "=" in trimmed and not trimmed.startswith("<"):
|
||||||
|
try:
|
||||||
|
pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True)
|
||||||
|
if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs):
|
||||||
|
out_pairs = []
|
||||||
|
for k, v in pairs:
|
||||||
|
k_lower = k.lower().replace("-", "_")
|
||||||
|
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
|
||||||
|
out_pairs.append((k, "[REDACTED]"))
|
||||||
|
else:
|
||||||
|
out_pairs.append((k, v))
|
||||||
|
return urllib.parse.urlencode(out_pairs)
|
||||||
|
except Exception as exc:
|
||||||
|
if SENSITIVE_KEY_PATTERN.search(trimmed):
|
||||||
|
raise ValueError(f"Malformed sensitive form request body: {exc}") from exc
|
||||||
|
|
||||||
|
# If unparsed body text contains sensitive keys or tokens, fail closed
|
||||||
|
if SENSITIVE_KEY_PATTERN.search(trimmed) or any(p in trimmed.lower() for p in SENSITIVE_PARAM_NAMES):
|
||||||
|
raise ValueError("Malformed or unsupported sensitive request body in trace entry")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_structured_obj(obj: Any) -> Any:
|
||||||
|
"""Recursively sanitize Python objects decoded from JSON or HAR payloads."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
new_dict: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Check for header entry with inverted or standard keys: {"name": ..., "value": ...}
|
||||||
|
name_val = obj.get("name")
|
||||||
|
val_val = obj.get("value")
|
||||||
|
if isinstance(name_val, str) and isinstance(val_val, str):
|
||||||
|
name_lower = name_val.lower().strip()
|
||||||
|
if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower):
|
||||||
|
for k, v in obj.items():
|
||||||
|
if k == "value":
|
||||||
|
new_dict[k] = "[REDACTED]"
|
||||||
|
else:
|
||||||
|
new_dict[k] = sanitize_structured_obj(v)
|
||||||
|
return new_dict
|
||||||
|
|
||||||
|
# Process standard dictionary keys
|
||||||
|
for k, v in obj.items():
|
||||||
|
k_lower = str(k).lower().replace("-", "_")
|
||||||
|
|
||||||
|
# Action parameters in Playwright traces (e.g. page.fill, page.type)
|
||||||
|
if k == "params" and isinstance(v, dict):
|
||||||
|
sanitized_params = sanitize_structured_obj(v)
|
||||||
|
# If this action involves fill, type, or sensitive inputs, redact value/text
|
||||||
|
if "value" in sanitized_params:
|
||||||
|
sanitized_params["value"] = "[REDACTED]"
|
||||||
|
if "text" in sanitized_params:
|
||||||
|
sanitized_params["text"] = "[REDACTED]"
|
||||||
|
new_dict[k] = sanitized_params
|
||||||
|
continue
|
||||||
|
|
||||||
|
# DOM snapshot nodes
|
||||||
|
if k == "attributes" and isinstance(v, (dict, list)):
|
||||||
|
if isinstance(v, dict):
|
||||||
|
attr_dict = dict(v)
|
||||||
|
type_val = str(attr_dict.get("type") or "").lower()
|
||||||
|
if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower():
|
||||||
|
if "value" in attr_dict:
|
||||||
|
attr_dict["value"] = "[REDACTED]"
|
||||||
|
new_dict[k] = attr_dict
|
||||||
|
continue
|
||||||
|
elif isinstance(v, list):
|
||||||
|
# List of attribute strings or pairs
|
||||||
|
new_list = []
|
||||||
|
is_pw = any("password" in str(x).lower() for x in v)
|
||||||
|
for x in v:
|
||||||
|
if is_pw and isinstance(x, str) and not x.startswith("type="):
|
||||||
|
new_list.append("[REDACTED]")
|
||||||
|
else:
|
||||||
|
new_list.append(sanitize_structured_obj(x))
|
||||||
|
new_dict[k] = new_list
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Request postData / text
|
||||||
|
if k in ("postData", "postDataText") and isinstance(v, str):
|
||||||
|
new_dict[k] = sanitize_body_text(v)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# URL fields
|
||||||
|
if k == "url" and isinstance(v, str):
|
||||||
|
new_dict[k] = sanitize_url(v)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Cookie arrays
|
||||||
|
if k == "cookies" and isinstance(v, list):
|
||||||
|
sanitized_cookies = []
|
||||||
|
for c in v:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
c_copy = dict(c)
|
||||||
|
if "value" in c_copy:
|
||||||
|
c_copy["value"] = "[REDACTED]"
|
||||||
|
sanitized_cookies.append(c_copy)
|
||||||
|
else:
|
||||||
|
sanitized_cookies.append(sanitize_structured_obj(c))
|
||||||
|
new_dict[k] = sanitized_cookies
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Direct sensitive key matches
|
||||||
|
if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str):
|
||||||
|
new_dict[k] = "[REDACTED]"
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_dict[k] = sanitize_structured_obj(v)
|
||||||
|
|
||||||
|
return new_dict
|
||||||
|
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
return [sanitize_structured_obj(item) for item in obj]
|
||||||
|
|
||||||
|
elif isinstance(obj, str):
|
||||||
|
# Check for inline credentials/tokens
|
||||||
|
if obj.startswith("Bearer ") or obj.startswith("bearer "):
|
||||||
|
return "Bearer [REDACTED]"
|
||||||
|
if obj.startswith("Basic ") or obj.startswith("basic "):
|
||||||
|
return "Basic [REDACTED]"
|
||||||
|
return obj
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_jsonl_content(data: bytes) -> bytes:
|
||||||
|
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks).
|
||||||
|
|
||||||
|
If any non-empty record cannot be decoded as valid UTF-8 or parsed as valid JSON,
|
||||||
|
or is malformed, raise ValueError fail-closed so the affected archive is permanently removed.
|
||||||
|
"""
|
||||||
|
lines = data.split(b"\n")
|
||||||
|
out_lines = []
|
||||||
|
for line in lines:
|
||||||
|
if not line.strip():
|
||||||
|
out_lines.append(line)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(line.decode("utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as err:
|
||||||
|
raise ValueError(f"Malformed or unsupported JSONL trace record: {err}")
|
||||||
|
sanitized = sanitize_structured_obj(parsed)
|
||||||
|
out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
||||||
|
return b"\n".join(out_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_trace_archive(zp: str) -> bool:
|
||||||
|
"""Sanitize a single zip archive in a strict fail-closed manner.
|
||||||
|
|
||||||
|
If the archive is malformed, partial, unreadable, or encounters an exception
|
||||||
|
during processing, the archive is permanently deleted so that raw traces
|
||||||
|
cannot leak.
|
||||||
|
Returns True if successfully sanitized, False if deleted due to error.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(zp):
|
||||||
|
return False
|
||||||
|
|
||||||
|
temp_zp = zp + ".tmp"
|
||||||
|
try:
|
||||||
|
if os.path.getsize(zp) == 0:
|
||||||
|
raise zipfile.BadZipFile("Empty archive")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zp, "r") as zin:
|
||||||
|
infolist = zin.infolist()
|
||||||
|
if not infolist:
|
||||||
|
raise zipfile.BadZipFile("Archive has no entries")
|
||||||
|
|
||||||
|
corrupt_file = zin.testzip()
|
||||||
|
if corrupt_file is not None:
|
||||||
|
raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for item in infolist:
|
||||||
|
filename_lower = item.filename.lower()
|
||||||
|
|
||||||
|
# 1. Binary image screenshots/diagnostics: preserve as-is
|
||||||
|
if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS):
|
||||||
|
content = zin.read(item.filename)
|
||||||
|
zout.writestr(item, content)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Embedded test source code (e.g. resources/src@... or .js/.ts):
|
||||||
|
# Mask safely without syntax corruption!
|
||||||
|
if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")):
|
||||||
|
safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n"
|
||||||
|
zout.writestr(item, safe_placeholder)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3. JSON Lines and structured JSON/HAR traces
|
||||||
|
content = zin.read(item.filename)
|
||||||
|
if filename_lower.endswith((".trace", ".network", ".stacks")):
|
||||||
|
sanitized_content = sanitize_jsonl_content(content)
|
||||||
|
zout.writestr(item, sanitized_content)
|
||||||
|
elif filename_lower.endswith((".json", ".har")):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(content.decode("utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
|
raise ValueError(f"Malformed JSON in archive member {item.filename}: {exc}") from exc
|
||||||
|
sanitized = sanitize_structured_obj(parsed)
|
||||||
|
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
||||||
|
else:
|
||||||
|
# Unknown binary or text member: exclude to fail closed
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Atomic replace
|
||||||
|
os.replace(temp_zp, zp)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc)
|
||||||
|
for path_to_remove in (temp_zp, zp):
|
||||||
|
if os.path.exists(path_to_remove):
|
||||||
|
try:
|
||||||
|
os.remove(path_to_remove)
|
||||||
|
except OSError as remove_err:
|
||||||
|
logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_directory(results_dir: str) -> int:
|
||||||
|
"""Walk results_dir and sanitize every .zip archive fail-closed.
|
||||||
|
|
||||||
|
Returns the number of archives processed. Exits with 1 if any archive fails.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(results_dir):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
all_ok = True
|
||||||
|
for root, _, files in os.walk(results_dir):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith(".zip"):
|
||||||
|
zp = os.path.join(root, f)
|
||||||
|
ok = sanitize_trace_archive(zp)
|
||||||
|
if not ok:
|
||||||
|
all_ok = False
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if not all_ok:
|
||||||
|
sys.exit(1)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <results_directory>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_dir = sys.argv[1]
|
||||||
|
sanitize_directory(target_dir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -11,6 +11,38 @@ import time
|
|||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_owned_server(server, identity):
|
||||||
|
"""Close only identity-bound descendants, including MCP's new sessions.
|
||||||
|
|
||||||
|
Capture before signalling the parent: an orphaned stderr holder can no
|
||||||
|
longer be attributed by ancestry. Never search by executable name or kill
|
||||||
|
the caller's process group.
|
||||||
|
"""
|
||||||
|
from src import process_lifecycle as lifecycle
|
||||||
|
|
||||||
|
descendants = (lifecycle.bind_descendants([identity.pid])
|
||||||
|
if identity.verdict() == lifecycle.OWNED else [])
|
||||||
|
if os.name == "nt":
|
||||||
|
if identity.verdict() == lifecycle.OWNED:
|
||||||
|
lifecycle.taskkill_tree(identity.pid)
|
||||||
|
else:
|
||||||
|
outcome = lifecycle.terminate_identities(
|
||||||
|
[*(entry.identity for entry in reversed(descendants)), identity],
|
||||||
|
steps=lifecycle.term_kill_steps(10),
|
||||||
|
)
|
||||||
|
if outcome.survivors or outcome.unverified:
|
||||||
|
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
|
||||||
|
try:
|
||||||
|
logs, _ = server.communicate(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
# Do not turn an unidentified pipe holder into an unbounded wait.
|
||||||
|
server.stdout.close()
|
||||||
|
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
|
||||||
|
return logs
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -36,7 +68,10 @@ def main():
|
|||||||
server = subprocess.Popen(
|
server = subprocess.Popen(
|
||||||
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
|
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
|
||||||
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
start_new_session=(os.name != "nt"),
|
||||||
)
|
)
|
||||||
|
from src.process_lifecycle import ProcessIdentity
|
||||||
|
server_identity = ProcessIdentity.capture(server.pid)
|
||||||
result = None
|
result = None
|
||||||
try:
|
try:
|
||||||
origin = f"http://127.0.0.1:{port}"
|
origin = f"http://127.0.0.1:{port}"
|
||||||
@@ -62,12 +97,7 @@ def main():
|
|||||||
print(result.stderr, file=sys.stderr)
|
print(result.stderr, file=sys.stderr)
|
||||||
return result.returncode
|
return result.returncode
|
||||||
finally:
|
finally:
|
||||||
server.terminate()
|
logs = _stop_owned_server(server, server_identity)
|
||||||
try:
|
|
||||||
logs, _ = server.communicate(timeout=10)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
server.kill()
|
|
||||||
logs, _ = server.communicate()
|
|
||||||
if result is None or result.returncode:
|
if result is None or result.returncode:
|
||||||
print(logs[-8000:], file=sys.stderr)
|
print(logs[-8000:], file=sys.stderr)
|
||||||
|
|
||||||
|
|||||||
@@ -202,17 +202,35 @@ def _infer_use_case(repo_id, pipeline_tag):
|
|||||||
return "general"
|
return "general"
|
||||||
|
|
||||||
|
|
||||||
|
# HF collections mix in models no Cookbook LLM backend can serve (vision
|
||||||
|
# encoders, time-series forecasters, image/video generators). Image models
|
||||||
|
# have their own registry, so keep these out of the LLM catalog.
|
||||||
|
_NON_LLM_PIPELINE_TAGS = frozenset({
|
||||||
|
"time-series-forecasting",
|
||||||
|
"zero-shot-image-classification",
|
||||||
|
"image-classification",
|
||||||
|
"image-feature-extraction",
|
||||||
|
"text-to-image",
|
||||||
|
"image-to-video",
|
||||||
|
"text-to-video",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _entry_from_collection_item(collection, item, source):
|
def _entry_from_collection_item(collection, item, source):
|
||||||
repo_id = item.get("id") or ""
|
repo_id = item.get("id") or ""
|
||||||
if item.get("type") != "model" or not repo_id:
|
if item.get("type") != "model" or not repo_id:
|
||||||
return None
|
return None
|
||||||
|
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
|
||||||
|
return None
|
||||||
repo_prefix = source.get("repo_prefix")
|
repo_prefix = source.get("repo_prefix")
|
||||||
if repo_prefix and not repo_id.startswith(repo_prefix):
|
if repo_prefix and not repo_id.startswith(repo_prefix):
|
||||||
return None
|
return None
|
||||||
raw_params = item.get("numParameters") or 0
|
raw_params = item.get("numParameters") or 0
|
||||||
active = None
|
# Read the name even when HF gives a total: it is the only source of the
|
||||||
|
# MoE active size (e.g. "-35B-A3B"), which the speed estimate depends on.
|
||||||
|
name_params, active = _parse_params_from_name(repo_id)
|
||||||
if not raw_params:
|
if not raw_params:
|
||||||
raw_params, active = _parse_params_from_name(repo_id)
|
raw_params = name_params
|
||||||
param_label, raw_params = _format_params(raw_params)
|
param_label, raw_params = _format_params(raw_params)
|
||||||
if not raw_params:
|
if not raw_params:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -628,6 +628,14 @@ async def extract_and_store(
|
|||||||
if not fact_text or len(fact_text) < 5:
|
if not fact_text or len(fact_text) < 5:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# A fuller owner-specific name must not rewrite a shared legacy
|
||||||
|
# entry, but that less-specific entry must not suppress it either.
|
||||||
|
legacy_refinements = {
|
||||||
|
id(entry) for entry in existing
|
||||||
|
if _owner and entry.get("owner") is None
|
||||||
|
and _name_enrichment_target(fact_text, [entry]) is entry
|
||||||
|
}
|
||||||
|
|
||||||
# Dedup: check vector similarity first (fast), then exact text match.
|
# Dedup: check vector similarity first (fast), then exact text match.
|
||||||
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
|
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
|
||||||
# remote endpoint down) must not abort the whole batch — fall through
|
# remote endpoint down) must not abort the whole batch — fall through
|
||||||
@@ -649,18 +657,25 @@ async def extract_and_store(
|
|||||||
# silently dropped. Mirror the owner predicate used by the
|
# silently dropped. Mirror the owner predicate used by the
|
||||||
# text dedup below; cross-tenant/stale matches fall through.
|
# text dedup below; cross-tenant/stale matches fall through.
|
||||||
_match = next((e for e in existing if e.get("id") == existing_id), None)
|
_match = next((e for e in existing if e.get("id") == existing_id), None)
|
||||||
if _match is not None and (_match.get("owner") == _owner or _match.get("owner") is None):
|
if (_match is not None and id(_match) not in legacy_refinements
|
||||||
|
and (_match.get("owner") == _owner or _match.get("owner") is None)):
|
||||||
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Text dedup fallback: exact match + fuzzy similarity
|
# Text dedup fallback: exact match + fuzzy similarity
|
||||||
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
|
user_existing = [e for e in existing
|
||||||
|
if (e.get("owner") == _owner or e.get("owner") is None)
|
||||||
|
and id(e) not in legacy_refinements] if _owner else existing
|
||||||
|
|
||||||
# Name enrichment: if an existing name fact is a strict prefix of
|
# Name enrichment: if an existing name fact is a strict prefix of
|
||||||
# this more complete one, upgrade it in place instead of adding a
|
# this more complete one, upgrade it in place instead of adding a
|
||||||
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
|
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
|
||||||
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
|
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
|
||||||
_enrich = _name_enrichment_target(fact_text, user_existing)
|
# Legacy unowned entries may be visible to multiple owners; never
|
||||||
|
# rewrite one with a private fact from the current owner.
|
||||||
|
_enrich = _name_enrichment_target(
|
||||||
|
fact_text, [e for e in user_existing if e.get("owner") == _owner]
|
||||||
|
)
|
||||||
if _enrich is not None:
|
if _enrich is not None:
|
||||||
_enrich["text"] = fact_text
|
_enrich["text"] = fact_text
|
||||||
_enrich["timestamp"] = int(time.time())
|
_enrich["timestamp"] = int(time.time())
|
||||||
|
|||||||
@@ -84,10 +84,14 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE)
|
|||||||
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
|
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
|
||||||
|
|
||||||
|
|
||||||
def _parse_scalar(raw: str) -> Any:
|
def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
if raw == "":
|
if raw == "":
|
||||||
return ""
|
return ""
|
||||||
|
# String fields keep their spelling; quoted values still use the decoder
|
||||||
|
# below so existing escapes round-trip as before.
|
||||||
|
if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')):
|
||||||
|
return raw
|
||||||
if raw.startswith("[") and raw.endswith("]"):
|
if raw.startswith("[") and raw.endswith("]"):
|
||||||
inner = raw[1:-1].strip()
|
inner = raw[1:-1].strip()
|
||||||
if not inner:
|
if not inner:
|
||||||
@@ -171,7 +175,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
|||||||
pending_key = key
|
pending_key = key
|
||||||
fm[key] = []
|
fm[key] = []
|
||||||
else:
|
else:
|
||||||
fm[key] = _parse_scalar(val)
|
fm[key] = _parse_scalar(val, string_only=(key == "version"))
|
||||||
pending_key = None
|
pending_key = None
|
||||||
continue
|
continue
|
||||||
m2 = _FM_BLOCK_LIST_RE.match(line)
|
m2 = _FM_BLOCK_LIST_RE.match(line)
|
||||||
|
|||||||
+156
-141
@@ -2834,7 +2834,7 @@ def _parse_qwen_explicit_admin_request(text: str) -> Optional[tuple[str, str]]:
|
|||||||
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
|
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
|
||||||
return "manage_settings", json.dumps({"action": "list_tools"})
|
return "manage_settings", json.dumps({"action": "list_tools"})
|
||||||
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
||||||
return "manage_settings", json.dumps({"action": "enable_tool", "tool": "images"})
|
return "manage_settings", json.dumps({"action": "list_tools"})
|
||||||
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
||||||
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
|
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
|
||||||
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
|
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
|
||||||
@@ -8057,7 +8057,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g.
|
|||||||
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
|
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
|
||||||
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
|
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
|
||||||
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
|
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
|
||||||
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool|enable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`.",
|
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`. You cannot re-enable a disabled tool: that is done by the user in Settings > Agent Tools.",
|
||||||
"manage_notes": """\
|
"manage_notes": """\
|
||||||
```manage_notes
|
```manage_notes
|
||||||
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
|
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
|
||||||
@@ -21061,6 +21061,103 @@ from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_author
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy):
|
||||||
|
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||||
|
disabled = set(disabled_tools or ())
|
||||||
|
from src.spec_router import latest_decision
|
||||||
|
_spec_decision = latest_decision() or {}
|
||||||
|
_spec_choice = _spec_decision.get("choice")
|
||||||
|
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||||
|
and decided and _spec_choice == "answer_directly")
|
||||||
|
try:
|
||||||
|
_mgr = get_mcp_manager()
|
||||||
|
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
||||||
|
except Exception:
|
||||||
|
mcp_schemas = []
|
||||||
|
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
||||||
|
if _spec_choice == "ask_user":
|
||||||
|
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
||||||
|
# can act when the detail is there or ask when it is not.
|
||||||
|
_top = _spec_decision.get("top") or []
|
||||||
|
if _top:
|
||||||
|
picks = set(picks) | {_top[0][0]}
|
||||||
|
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
||||||
|
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||||
|
base_offered = set(turn_contract.offered)
|
||||||
|
# The router sees the full catalog, while this request's policy may
|
||||||
|
# deny a tool (for example, a browser turn with Web access switched
|
||||||
|
# off). Preserve that conflict as an unavailable contract. Keeping the
|
||||||
|
# old empty menu instead lets the model invent a tool result in prose.
|
||||||
|
_top = _spec_decision.get("top") or []
|
||||||
|
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
||||||
|
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
||||||
|
_primary = f"mcp__email__{_primary}"
|
||||||
|
# A policy denial is surfaced even if the router chose outside the
|
||||||
|
# base inventory; it never grants access to that tool.
|
||||||
|
def _denied(name):
|
||||||
|
return (name in disabled
|
||||||
|
or (tool_policy is not None and (
|
||||||
|
tool_policy.blocks(name)
|
||||||
|
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
||||||
|
_unavailable_pick = ""
|
||||||
|
if _primary in picks and _denied(_primary):
|
||||||
|
_unavailable_pick = _primary
|
||||||
|
elif picks and all(_denied(name) for name in picks):
|
||||||
|
_unavailable_pick = sorted(picks)[0]
|
||||||
|
if _unavailable_pick:
|
||||||
|
blocked = replace(
|
||||||
|
turn_contract, required=frozenset(), offered=frozenset(),
|
||||||
|
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
||||||
|
required_read_operation=None, routing_experiment="spec-policy-denied",
|
||||||
|
)
|
||||||
|
_spec_active_contract.set(blocked)
|
||||||
|
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
||||||
|
return blocked
|
||||||
|
want = set(picks) - disabled
|
||||||
|
# Always-on basics only when the base turn offers them, so the Spec
|
||||||
|
# arm never offers a tool the base arm would not.
|
||||||
|
if not _spec_direct:
|
||||||
|
want |= {"ask_user", "update_plan"} & base_offered
|
||||||
|
if not (strict and decided):
|
||||||
|
want |= base_offered
|
||||||
|
want &= base_offered
|
||||||
|
if not want:
|
||||||
|
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
||||||
|
sorted(picks), sorted(base_offered))
|
||||||
|
return turn_contract
|
||||||
|
offer = want & base_offered & set(turn_contract.executable)
|
||||||
|
# A router may narrow the menu only when every existing obligation
|
||||||
|
# remains satisfiable. It cannot redefine the user's requested work.
|
||||||
|
required_read = getattr(turn_contract, "required_read_operation", None)
|
||||||
|
if (not set(turn_contract.required) <= offer or turn_contract.unavailable
|
||||||
|
or (required_read is not None and required_read.tool not in offer)):
|
||||||
|
return turn_contract
|
||||||
|
by_name = {}
|
||||||
|
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
||||||
|
nm = (sc.get("function") or {}).get("name")
|
||||||
|
if nm in offer and nm not in by_name:
|
||||||
|
by_name[nm] = sc
|
||||||
|
for sc in turn_contract.schemas():
|
||||||
|
nm = (sc.get("function") or {}).get("name")
|
||||||
|
if nm in offer:
|
||||||
|
by_name[nm] = sc
|
||||||
|
if not by_name and not _spec_direct:
|
||||||
|
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
||||||
|
return turn_contract
|
||||||
|
# Required tools and the required read carry over unchanged: harness
|
||||||
|
# completion logic keys on them (e.g. a calendar list is a read, not an
|
||||||
|
# effect that may end with "Done."). If one lost its schema, contract
|
||||||
|
# validation fails and the original contract is retained below.
|
||||||
|
new = replace(
|
||||||
|
turn_contract, offered=frozenset(by_name),
|
||||||
|
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
||||||
|
routing_experiment="spec-router")
|
||||||
|
_spec_active_contract.set(new)
|
||||||
|
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
||||||
|
sorted(by_name), sorted(turn_contract.offered), decided)
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||||
tool_policy=None):
|
tool_policy=None):
|
||||||
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
|
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
|
||||||
@@ -21069,10 +21166,17 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
|
|||||||
Runs before any runtime dispatch so the compact preview runtime and the
|
Runs before any runtime dispatch so the compact preview runtime and the
|
||||||
main loop both serve the router's set; only the offered tools change.
|
main loop both serve the router's set; only the offered tools change.
|
||||||
"""
|
"""
|
||||||
|
# Contexts can be inherited by a child task; a turn must never inherit a
|
||||||
|
# previous turn's decision when routing is disabled or unavailable.
|
||||||
|
from src import spec_router as _router_context
|
||||||
|
if hasattr(_router_context, "clear_decision"):
|
||||||
|
_router_context.clear_decision()
|
||||||
if (turn_contract is None
|
if (turn_contract is None
|
||||||
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||||
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||||
return turn_contract
|
return turn_contract
|
||||||
|
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
||||||
|
return turn_contract
|
||||||
try:
|
try:
|
||||||
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||||
return turn_contract
|
return turn_contract
|
||||||
@@ -21080,105 +21184,38 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
|
|||||||
return turn_contract
|
return turn_contract
|
||||||
try:
|
try:
|
||||||
from src.spec_router import spec_route_tools as _spec_route_tools_early
|
from src.spec_router import spec_route_tools as _spec_route_tools_early
|
||||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
|
||||||
disabled = set(disabled_tools or ())
|
|
||||||
picks, decided = _spec_route_tools_early(
|
picks, decided = _spec_route_tools_early(
|
||||||
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||||
from src.spec_router import LAST_DECISIONS as _spec_decisions
|
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
||||||
_spec_choice = (_spec_decisions[-1] if _spec_decisions else {}).get("choice")
|
|
||||||
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
|
||||||
and decided and _spec_choice == "answer_directly")
|
|
||||||
try:
|
|
||||||
_mgr = get_mcp_manager()
|
|
||||||
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
|
||||||
except Exception:
|
|
||||||
mcp_schemas = []
|
|
||||||
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
|
||||||
if _spec_choice == "ask_user":
|
|
||||||
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
|
||||||
# can act when the detail is there or ask when it is not.
|
|
||||||
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
|
||||||
if _top:
|
|
||||||
picks = set(picks) | {_top[0][0]}
|
|
||||||
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
|
||||||
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
|
||||||
base_offered = set(turn_contract.offered)
|
|
||||||
subset_of_base = os.getenv("ODYSSEUS_SPEC_SUBSET_OF_BASE", "1").strip().lower() not in {"0", "false", "no", "off"}
|
|
||||||
# The router sees the full catalog, while this request's policy may
|
|
||||||
# deny a tool (for example, a browser turn with Web access switched
|
|
||||||
# off). Preserve that conflict as an unavailable contract. Keeping the
|
|
||||||
# old empty menu instead lets the model invent a tool result in prose.
|
|
||||||
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
|
||||||
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
|
||||||
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
|
||||||
_primary = f"mcp__email__{_primary}"
|
|
||||||
# `executable` belongs to the base contract. In subset=0, Spec may
|
|
||||||
# legitimately add a policy-permitted tool that the base inventory did
|
|
||||||
# not include. Only an explicit request-policy denial blocks the pick.
|
|
||||||
def _denied(name):
|
|
||||||
return (name in disabled
|
|
||||||
or (tool_policy is not None and (
|
|
||||||
tool_policy.blocks(name)
|
|
||||||
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
|
||||||
_unavailable_pick = ""
|
|
||||||
if _primary in picks and _denied(_primary):
|
|
||||||
_unavailable_pick = _primary
|
|
||||||
elif picks and all(_denied(name) for name in picks):
|
|
||||||
_unavailable_pick = sorted(picks)[0]
|
|
||||||
if _unavailable_pick:
|
|
||||||
blocked = replace(
|
|
||||||
turn_contract, required=frozenset(), offered=frozenset(),
|
|
||||||
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
|
||||||
required_read_operation=None, routing_experiment="spec-policy-denied",
|
|
||||||
)
|
|
||||||
_spec_active_contract.set(blocked)
|
|
||||||
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
|
||||||
return blocked
|
|
||||||
want = set(picks) - disabled
|
|
||||||
# Always-on basics only when the base turn offers them, so the Spec
|
|
||||||
# arm never offers a tool the base arm would not.
|
|
||||||
if not _spec_direct:
|
|
||||||
want |= {"ask_user", "update_plan"} & base_offered
|
|
||||||
if not (strict and decided):
|
|
||||||
want |= base_offered
|
|
||||||
if subset_of_base:
|
|
||||||
# A/B control: Spec offered set is a subset of the base offered set
|
|
||||||
# (measures narrowing only). Empty intersection keeps the base set.
|
|
||||||
want &= base_offered
|
|
||||||
if not want:
|
|
||||||
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
|
||||||
sorted(picks), sorted(base_offered))
|
|
||||||
return turn_contract
|
|
||||||
offer = want if strict else (want & set(turn_contract.executable))
|
|
||||||
by_name = {}
|
|
||||||
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
|
||||||
nm = (sc.get("function") or {}).get("name")
|
|
||||||
if nm in offer and nm not in by_name:
|
|
||||||
by_name[nm] = sc
|
|
||||||
for sc in turn_contract.schemas():
|
|
||||||
nm = (sc.get("function") or {}).get("name")
|
|
||||||
if nm in offer:
|
|
||||||
by_name[nm] = sc
|
|
||||||
if not by_name and not _spec_direct:
|
|
||||||
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
|
||||||
return turn_contract
|
|
||||||
# Keep the contract's required tools and required read whenever they
|
|
||||||
# are still offered: harness completion logic keys on them (e.g. a
|
|
||||||
# calendar list is a read, not an effect that may end with "Done.").
|
|
||||||
_rro = getattr(turn_contract, "required_read_operation", None)
|
|
||||||
_keep_rro = _rro if (_rro is not None and getattr(_rro, "tool", None) in by_name) else None
|
|
||||||
new = replace(
|
|
||||||
turn_contract, required=frozenset(n for n in (turn_contract.required or ()) if n in by_name),
|
|
||||||
offered=frozenset(by_name),
|
|
||||||
executable=frozenset(set(turn_contract.executable) | set(by_name)),
|
|
||||||
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
|
||||||
unavailable=(frozenset() if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" else turn_contract.unavailable), required_read_operation=_keep_rro, routing_experiment="spec-router")
|
|
||||||
_spec_active_contract.set(new)
|
|
||||||
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
|
||||||
sorted(by_name), sorted(turn_contract.offered), decided)
|
|
||||||
return new
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("[spec-harness] router contract replacement failed open: %s", exc)
|
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
||||||
|
return turn_contract
|
||||||
|
|
||||||
|
|
||||||
|
async def _spec_router_contract_async(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||||
|
tool_policy=None):
|
||||||
|
"""Asynchronous non-blocking turn contract routing."""
|
||||||
|
from src import spec_router as _router_context
|
||||||
|
if hasattr(_router_context, "clear_decision"):
|
||||||
|
_router_context.clear_decision()
|
||||||
|
if (turn_contract is None
|
||||||
|
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||||
|
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||||
|
return turn_contract
|
||||||
|
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
||||||
|
return turn_contract
|
||||||
|
try:
|
||||||
|
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||||
|
return turn_contract
|
||||||
|
except Exception:
|
||||||
|
return turn_contract
|
||||||
|
try:
|
||||||
|
from src.spec_router import spec_route_tools_async as _spec_route_tools_early_async
|
||||||
|
picks, decided = await _spec_route_tools_early_async(
|
||||||
|
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||||
|
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
||||||
return turn_contract
|
return turn_contract
|
||||||
|
|
||||||
|
|
||||||
@@ -21263,20 +21300,11 @@ async def stream_agent_loop(
|
|||||||
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
|
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
|
||||||
and not (tool_policy and tool_policy.block_all_tool_calls)
|
and not (tool_policy and tool_policy.block_all_tool_calls)
|
||||||
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
|
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
|
||||||
from src.spec_driver import stream_spec_turn
|
yield 'data: ' + json.dumps({"delta": "Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed."}) + '\n\n'
|
||||||
async for chunk in stream_spec_turn(
|
yield 'data: [DONE]\n\n'
|
||||||
endpoint_url=endpoint_url, model=model, messages=messages, headers=headers,
|
|
||||||
session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
|
||||||
tool_policy=tool_policy, workspace=workspace, active_document=active_document,
|
|
||||||
client_runtime_context=client_runtime_context,
|
|
||||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
|
||||||
max_tokens=max(1024, min(int(max_tokens or 1024), 4096)), max_rounds=8,
|
|
||||||
temperature=0.0, request_authority=request_authority,
|
|
||||||
):
|
|
||||||
yield chunk
|
|
||||||
return
|
return
|
||||||
|
|
||||||
turn_contract = _spec_router_contract(
|
turn_contract = await _spec_router_contract_async(
|
||||||
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||||
tool_policy=tool_policy)
|
tool_policy=tool_policy)
|
||||||
if (turn_contract is not None
|
if (turn_contract is not None
|
||||||
@@ -21289,6 +21317,20 @@ async def stream_agent_loop(
|
|||||||
yield 'data: [DONE]\n\n'
|
yield 'data: [DONE]\n\n'
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
||||||
|
except Exception:
|
||||||
|
_spec_turn = False
|
||||||
|
# Without a turn contract (scheduled, skill and monitor runs) no Spec
|
||||||
|
# routing applies; the turn runs on the compact surface as usual.
|
||||||
|
if _spec_turn and _blocks_before_inference(turn_contract):
|
||||||
|
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||||
|
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||||
|
yield 'data: ' + json.dumps({"delta": "I can’t perform this request with the currently permitted tools "
|
||||||
|
f"(unavailable: {unavailable}). I haven’t substituted another tool."}) + '\n\n'
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
|
return
|
||||||
|
|
||||||
if is_compact_preview_contract(turn_contract):
|
if is_compact_preview_contract(turn_contract):
|
||||||
from src.clean_agent_preview import stream_preview
|
from src.clean_agent_preview import stream_preview
|
||||||
async for chunk in stream_preview(
|
async for chunk in stream_preview(
|
||||||
@@ -21314,26 +21356,9 @@ async def stream_agent_loop(
|
|||||||
yield chunk
|
yield chunk
|
||||||
return
|
return
|
||||||
|
|
||||||
_spec_turn = False
|
|
||||||
try:
|
|
||||||
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
|
||||||
except Exception:
|
|
||||||
_spec_turn = False
|
|
||||||
if _spec_turn and turn_contract is not None and getattr(turn_contract, "routing_experiment", "") != "spec-router":
|
|
||||||
# Ajax Spec: the router, not the regex turn contract, decides the tools.
|
|
||||||
# Drop the contract here and in the dispatcher's context so neither the
|
|
||||||
# route build nor execute_tool_block enforces its offered set.
|
|
||||||
logger.info("[spec-harness] dropping turn contract on spec turn: offered=%s unavailable=%s",
|
|
||||||
sorted(turn_contract.offered), sorted(turn_contract.unavailable))
|
|
||||||
turn_contract = None
|
|
||||||
try:
|
|
||||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
|
||||||
_spec_active_contract.set(None)
|
|
||||||
except Exception as _exc:
|
|
||||||
logger.warning("[spec-harness] could not clear active turn contract: %s", _exc)
|
|
||||||
if turn_contract is not None:
|
if turn_contract is not None:
|
||||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||||
if not _spec_turn and _blocks_before_inference(turn_contract):
|
if _blocks_before_inference(turn_contract):
|
||||||
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||||
clarification = (
|
clarification = (
|
||||||
"I can’t perform this request with the currently permitted tools "
|
"I can’t perform this request with the currently permitted tools "
|
||||||
@@ -24336,7 +24361,7 @@ async def stream_agent_loop(
|
|||||||
# "spec" = compact tools plus a small-model router; the harness-facing
|
# "spec" = compact tools plus a small-model router; the harness-facing
|
||||||
# surface already reads "compact", so detect spec from the raw mode.
|
# surface already reads "compact", so detect spec from the raw mode.
|
||||||
try:
|
try:
|
||||||
_spec_surface = _configured_model_tool_surface_raw(
|
_spec_surface = getattr(turn_contract, "routing_experiment", "") == "spec-router" and _configured_model_tool_surface_raw(
|
||||||
candidate_url, candidate_model, owner, headers=candidate_headers,
|
candidate_url, candidate_model, owner, headers=candidate_headers,
|
||||||
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
|
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -24410,7 +24435,7 @@ async def stream_agent_loop(
|
|||||||
if _spec_mode == "strict" and _spec_decided:
|
if _spec_mode == "strict" and _spec_decided:
|
||||||
# The router decides which tools are injected: its pick(s),
|
# The router decides which tools are injected: its pick(s),
|
||||||
# tools already used in this chat, and the always-on basics.
|
# tools already used in this chat, and the always-on basics.
|
||||||
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) - set(disabled_tools)
|
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) & set(turn_contract.offered) - set(disabled_tools)
|
||||||
if _spec_strict:
|
if _spec_strict:
|
||||||
route_tools = _spec_strict
|
route_tools = _spec_strict
|
||||||
_spec_offered = frozenset(route_tools)
|
_spec_offered = frozenset(route_tools)
|
||||||
@@ -25361,15 +25386,6 @@ async def stream_agent_loop(
|
|||||||
if (schema.get("function", {}).get("name") or schema.get("name"))
|
if (schema.get("function", {}).get("name") or schema.get("name"))
|
||||||
in _spec_names
|
in _spec_names
|
||||||
]
|
]
|
||||||
_have = {
|
|
||||||
(schema.get("function", {}).get("name") or schema.get("name"))
|
|
||||||
for schema in contract_schemas
|
|
||||||
}
|
|
||||||
contract_schemas += [
|
|
||||||
schema for schema in list(FUNCTION_TOOL_SCHEMAS) + list(route_mcp_schemas)
|
|
||||||
if schema.get("function", {}).get("name") in (set(_spec_names) - _have)
|
|
||||||
and schema.get("function", {}).get("name") not in disabled_tools
|
|
||||||
]
|
|
||||||
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
||||||
if route_state["is_api_model"]:
|
if route_state["is_api_model"]:
|
||||||
if tool_surface == "full":
|
if tool_surface == "full":
|
||||||
@@ -26321,7 +26337,7 @@ async def stream_agent_loop(
|
|||||||
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
||||||
if round_num == 1:
|
if round_num == 1:
|
||||||
try:
|
try:
|
||||||
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or "")), (m.get("metadata") or {}).get("source"), str(m.get("content") or "")[:40].replace("\n", " ")) for m in messages])
|
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or ""))) for m in messages])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
|
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
|
||||||
@@ -33925,7 +33941,6 @@ async def stream_agent_loop(
|
|||||||
"mutation_epoch": _workspace_mutation_epoch,
|
"mutation_epoch": _workspace_mutation_epoch,
|
||||||
"error": _failure_text[:600],
|
"error": _failure_text[:600],
|
||||||
}
|
}
|
||||||
logger.info("[spec-debug] tool failure text: %s", _failure_text[:400])
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[agent] recorded failed call %s in round %s at mutation epoch %s",
|
"[agent] recorded failed call %s in round %s at mutation epoch %s",
|
||||||
_call_signature,
|
_call_signature,
|
||||||
|
|||||||
@@ -585,7 +585,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
return _ALIASES_SET.get(k2, (k or "").strip())
|
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||||
|
|
||||||
def _is_managed_key(key):
|
def _is_managed_key(key):
|
||||||
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
|
# Tool policy is not a general setting, even if a future default
|
||||||
|
# is added: resetting it must never re-enable disabled tools.
|
||||||
|
return key != "disabled_tools" and key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
|
||||||
|
|
||||||
_ENUMS = {
|
_ENUMS = {
|
||||||
"image_quality": ["low", "medium", "high"],
|
"image_quality": ["low", "medium", "high"],
|
||||||
@@ -713,13 +715,35 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
save_settings(s)
|
save_settings(s)
|
||||||
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
||||||
|
|
||||||
elif action in ("disable_tool", "enable_tool", "list_tools"):
|
elif action == "enable_tool":
|
||||||
|
# The global disabled_tools denylist is the one hard permission
|
||||||
|
# boundary the Settings -> Agent Tools panel offers admins. It
|
||||||
|
# must only ever be *narrowed* from inside an agent turn: a
|
||||||
|
# model that can call enable_tool can undo the boundary itself,
|
||||||
|
# and any web page, email, or document the agent reads can ask
|
||||||
|
# it to (prompt injection re-enabling bash/email/etc.). Widening
|
||||||
|
# the tool set stays a human action in the Settings panel.
|
||||||
|
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
|
||||||
|
return {
|
||||||
|
"error": (
|
||||||
|
f"Re-enabling tools is not available from chat"
|
||||||
|
f"{f' (requested: {tool_name})' if tool_name else ''}. "
|
||||||
|
"Disabled tools are an admin policy: ask the user to turn the tool "
|
||||||
|
"back on in Settings > Agent Tools. You can still disable tools "
|
||||||
|
"(disable_tool) or list the current state (list_tools)."
|
||||||
|
),
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
elif action in ("disable_tool", "list_tools"):
|
||||||
# Tool-toggle actions. These edit settings.json:disabled_tools
|
# Tool-toggle actions. These edit settings.json:disabled_tools
|
||||||
# (the global list read on every chat request) rather than
|
# (the global list read on every chat request) rather than
|
||||||
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
||||||
# "search" -> "web_search", "browser" -> browser tools,
|
# "search" -> "web_search", "browser" -> browser tools,
|
||||||
# "documents" -> the document tool set, "memory" ->
|
# "documents" -> the document tool set, "memory" ->
|
||||||
# manage_memory, etc.
|
# manage_memory, etc. Only disable_tool mutates: see the
|
||||||
|
# enable_tool branch above for why the agent can never widen
|
||||||
|
# the denylist.
|
||||||
from src.settings import get_setting, save_settings, load_settings
|
from src.settings import get_setting, save_settings, load_settings
|
||||||
_ALIASES = {
|
_ALIASES = {
|
||||||
"shell": ["bash"],
|
"shell": ["bash"],
|
||||||
@@ -753,7 +777,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
"response": (
|
"response": (
|
||||||
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
||||||
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
||||||
"memory, skills, images, tasks, notes, calendar, email."
|
"memory, skills, images, tasks, notes, calendar, email. "
|
||||||
|
"To re-enable a tool, open Settings > Agent Tools."
|
||||||
),
|
),
|
||||||
"disabled": list(current),
|
"disabled": list(current),
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
@@ -767,22 +792,19 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
current = list(settings.get("disabled_tools") or [])
|
current = list(settings.get("disabled_tools") or [])
|
||||||
before = set(current)
|
before = set(current)
|
||||||
if action == "disable_tool":
|
for t in targets:
|
||||||
for t in targets:
|
if t not in current:
|
||||||
if t not in current:
|
current.append(t)
|
||||||
current.append(t)
|
|
||||||
else: # enable_tool
|
|
||||||
current = [t for t in current if t not in targets]
|
|
||||||
after = set(current)
|
after = set(current)
|
||||||
settings["disabled_tools"] = current
|
settings["disabled_tools"] = current
|
||||||
save_settings(settings)
|
save_settings(settings)
|
||||||
|
|
||||||
verb = "Disabled" if action == "disable_tool" else "Enabled"
|
|
||||||
changed = sorted(after.symmetric_difference(before))
|
changed = sorted(after.symmetric_difference(before))
|
||||||
return {
|
return {
|
||||||
"response": (
|
"response": (
|
||||||
f"{verb} {tool_name} ({', '.join(targets)}). "
|
f"Disabled {tool_name} ({', '.join(targets)}). "
|
||||||
f"Now disabled: {', '.join(current) if current else '(none)'}."
|
f"Now disabled: {', '.join(current) if current else '(none)'}. "
|
||||||
|
"Re-enabling is done by the user in Settings > Agent Tools."
|
||||||
),
|
),
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
"disabled": list(current),
|
"disabled": list(current),
|
||||||
|
|||||||
@@ -78,7 +78,16 @@ def _parse(content: Any) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
||||||
return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default")
|
# Explicit editor session bound in context is authoritative;
|
||||||
|
# otherwise fallback to caller/tool args, then default.
|
||||||
|
# Note: ctx.get("session_id") is the chat/turn UUID, NOT the editor canvas session.
|
||||||
|
return str(ctx.get("editor_session") or args.get("session") or args.get("editor_session") or "default")
|
||||||
|
|
||||||
|
|
||||||
|
def _incognito(ctx: Dict[str, Any]) -> bool:
|
||||||
|
runtime = ctx.get("client_runtime_context")
|
||||||
|
state = runtime.get("web_ui_state") if isinstance(runtime, dict) else None
|
||||||
|
return ctx.get("incognito") is True or (isinstance(state, dict) and state.get("incognito") is True)
|
||||||
|
|
||||||
|
|
||||||
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
|
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
|
||||||
@@ -107,17 +116,26 @@ def _data_url_bytes(data_url: str) -> bytes:
|
|||||||
return base64.b64decode(b64)
|
return base64.b64decode(b64)
|
||||||
|
|
||||||
|
|
||||||
|
def _image_reference_path(value: str) -> bool:
|
||||||
|
# Never use the internal tool credential to fetch an arbitrary app route.
|
||||||
|
generated = re.fullmatch(r"/api/generated-image/[a-f0-9]{8,64}\.(?:png|jpg|jpeg|webp|gif)", value)
|
||||||
|
upload = re.fullmatch(r"/api/upload/[a-fA-F0-9]{32}(?:\.[A-Za-z0-9]+)?", value)
|
||||||
|
return bool(generated or upload)
|
||||||
|
|
||||||
|
|
||||||
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
|
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
|
||||||
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
|
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
|
||||||
from core.database import GalleryImage, SessionLocal
|
from core.database import GalleryImage, SessionLocal
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
image_path = None
|
||||||
try:
|
try:
|
||||||
root = Path(GENERATED_IMAGES_DIR).resolve()
|
root = Path(GENERATED_IMAGES_DIR).resolve()
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
(root / filename).write_bytes(image_bytes)
|
image_path = root / filename
|
||||||
|
image_path.write_bytes(image_bytes)
|
||||||
new_id = str(uuid.uuid4())
|
new_id = str(uuid.uuid4())
|
||||||
width = height = 0
|
width = height = 0
|
||||||
try:
|
try:
|
||||||
@@ -163,6 +181,11 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
|
|||||||
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
|
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
if image_path is not None:
|
||||||
|
try:
|
||||||
|
image_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Failed to remove uncommitted editor image", exc_info=True)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -198,7 +221,7 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
|
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
|
||||||
return data.get("ok") is not False
|
return bool(data.get("ok") is True and data.get("completed", True) is not False)
|
||||||
except Exception as exc: # pragma: no cover - defensive
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
logger.warning("editor layer placement failed: %s", exc)
|
logger.warning("editor layer placement failed: %s", exc)
|
||||||
return False
|
return False
|
||||||
@@ -367,28 +390,46 @@ class RunEditorCommandTool:
|
|||||||
args = _parse(content)
|
args = _parse(content)
|
||||||
op = str(args.get("op") or "").strip()
|
op = str(args.get("op") or "").strip()
|
||||||
if not op:
|
if not op:
|
||||||
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1}
|
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1, "completed": False}
|
||||||
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
||||||
|
wait = bool(args.get("wait", True))
|
||||||
payload = {
|
payload = {
|
||||||
"session": _session(args, ctx),
|
"session": _session(args, ctx),
|
||||||
"op": op,
|
"op": op,
|
||||||
"params": params,
|
"params": params,
|
||||||
"wait": bool(args.get("wait", True)),
|
"wait": wait,
|
||||||
"timeout": float(args.get("timeout") or 60),
|
"timeout": float(args.get("timeout") or 60),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
|
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1}
|
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1, "completed": False, "status": "failed"}
|
||||||
ok = data.get("ok")
|
|
||||||
|
is_queued = bool(data.get("queued")) or data.get("status") == "queued"
|
||||||
|
if is_queued and (not wait or not data.get("ok")):
|
||||||
|
summary = json.dumps({"status": "queued", "queued": data.get("queued"), "session": data.get("session")}, ensure_ascii=False)
|
||||||
|
return f"run_editor_command: {op}", {
|
||||||
|
"output": summary,
|
||||||
|
"queued": data.get("queued"),
|
||||||
|
"status": "queued",
|
||||||
|
"pending": True,
|
||||||
|
"completed": False,
|
||||||
|
"ok": False,
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
ok = bool(data.get("ok") is True and data.get("completed", True) is not False)
|
||||||
|
status = data.get("status") or ("completed" if ok else "failed")
|
||||||
result = data.get("result")
|
result = data.get("result")
|
||||||
summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
summary = json.dumps({"ok": ok, "status": status, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
||||||
return f"run_editor_command: {op}", {
|
return f"run_editor_command: {op}", {
|
||||||
"output": summary[:4000],
|
"output": summary[:4000],
|
||||||
"result": result,
|
"result": result,
|
||||||
"screenshot": data.get("screenshot"),
|
"screenshot": data.get("screenshot"),
|
||||||
|
"status": status,
|
||||||
|
"completed": ok,
|
||||||
"ok": ok,
|
"ok": ok,
|
||||||
"exit_code": 0 if ok is not False else 1,
|
"exit_code": 0 if ok else 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -442,10 +483,14 @@ class AiEditImageTool:
|
|||||||
image = _as_data_url(args.get("image"))
|
image = _as_data_url(args.get("image"))
|
||||||
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
|
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
|
||||||
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
|
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
|
||||||
|
if not _image_reference_path(args["image"]):
|
||||||
|
return "ai_edit_image: invalid image", {"error": "Invalid image reference.", "exit_code": 1}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=60) as client:
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
|
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
if not resp.headers.get("content-type", "").lower().startswith("image/"):
|
||||||
|
return "ai_edit_image: invalid image", {"error": "Image reference is not an image.", "exit_code": 1}
|
||||||
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
|
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
|
||||||
except Exception:
|
except Exception:
|
||||||
image = None
|
image = None
|
||||||
@@ -550,10 +595,13 @@ class AiEditImageTool:
|
|||||||
"model": data.get("model") or model_choice or "",
|
"model": data.get("model") or model_choice or "",
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
try:
|
if _incognito(ctx):
|
||||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
info["image_url"] = data_url
|
||||||
except Exception as exc:
|
else:
|
||||||
logger.warning("ai_edit_image persist failed: %s", exc)
|
try:
|
||||||
|
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ai_edit_image persist failed: %s", exc)
|
||||||
_t["persist"] = time.perf_counter()
|
_t["persist"] = time.perf_counter()
|
||||||
if bool(args.get("place", True)):
|
if bool(args.get("place", True)):
|
||||||
placed = await _place_layer(
|
placed = await _place_layer(
|
||||||
@@ -626,10 +674,13 @@ class GenerateImageLayerTool:
|
|||||||
"model": data.get("image_model") or str(args.get("model") or ""),
|
"model": data.get("image_model") or str(args.get("model") or ""),
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
try:
|
if _incognito(ctx):
|
||||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
info["image_url"] = data_url
|
||||||
except Exception as exc:
|
else:
|
||||||
logger.warning("generate_image persist failed: %s", exc)
|
try:
|
||||||
|
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("generate_image persist failed: %s", exc)
|
||||||
if bool(args.get("place", True)):
|
if bool(args.get("place", True)):
|
||||||
placed = await _place_layer(
|
placed = await _place_layer(
|
||||||
data_url,
|
data_url,
|
||||||
|
|||||||
@@ -1207,7 +1207,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
Line 1: prompt describing the image
|
Line 1: prompt describing the image
|
||||||
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
|
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
|
||||||
Line 3: size (optional, defaults to 1024x1024)
|
Line 3: size (optional, defaults to 1024x1024)
|
||||||
Line 4: quality (optional, defaults to medium — options: low, medium, high, auto)
|
Line 4: quality (optional, defaults to configured quality or medium — low, medium, high, auto)
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import httpx
|
import httpx
|
||||||
@@ -1225,13 +1225,13 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
prompt = str(args.get('prompt') or '').strip()
|
prompt = str(args.get('prompt') or '').strip()
|
||||||
model_spec = str(args.get('model') or '').strip()
|
model_spec = str(args.get('model') or '').strip()
|
||||||
size = str(args.get('size') or '1024x1024')
|
size = str(args.get('size') or '1024x1024')
|
||||||
quality = str(args.get('quality') or 'medium')
|
quality = str(args.get('quality') or '')
|
||||||
else:
|
else:
|
||||||
lines = content.strip().split("\n")
|
lines = content.strip().split("\n")
|
||||||
prompt = lines[0].strip() if lines else ""
|
prompt = lines[0].strip() if lines else ""
|
||||||
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
|
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
|
||||||
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
|
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
|
||||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
|
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else ""
|
||||||
|
|
||||||
if not prompt:
|
if not prompt:
|
||||||
return {"error": "Image prompt is required (line 1)"}
|
return {"error": "Image prompt is required (line 1)"}
|
||||||
@@ -1249,8 +1249,8 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
# Use admin-configured model/quality if not specified by the tool call
|
# Use admin-configured model/quality if not specified by the tool call
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
model_spec = _settings.get("image_model", "")
|
model_spec = _settings.get("image_model", "")
|
||||||
if quality == "medium" and _settings.get("image_quality"):
|
if not quality:
|
||||||
quality = _settings["image_quality"]
|
quality = _settings.get("image_quality") or "medium"
|
||||||
|
|
||||||
# Auto-detect best available image model if still not set
|
# Auto-detect best available image model if still not set
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
@@ -1482,7 +1482,7 @@ async def do_edit_image(
|
|||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
size: str = "auto",
|
size: str = "auto",
|
||||||
quality: str = "medium",
|
quality: Optional[str] = None,
|
||||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||||
incognito: bool = False,
|
incognito: bool = False,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
@@ -1509,8 +1509,8 @@ async def do_edit_image(
|
|||||||
|
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
model_spec = _settings.get("image_model", "")
|
model_spec = _settings.get("image_model", "")
|
||||||
if quality == "medium" and _settings.get("image_quality"):
|
if quality is None:
|
||||||
quality = _settings["image_quality"]
|
quality = _settings.get("image_quality") or "medium"
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
return {"error": "No image model selected for image editing"}
|
return {"error": "No image model selected for image editing"}
|
||||||
|
|
||||||
|
|||||||
@@ -211,10 +211,15 @@ class ChatHandler:
|
|||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
vision_enabled = get_setting("vision_enabled", True)
|
vision_enabled = get_setting("vision_enabled", True)
|
||||||
if vision_enabled:
|
if vision_enabled:
|
||||||
|
from src.owner_identity import auth_disabled
|
||||||
|
sess_owner = getattr(sess, "owner", None)
|
||||||
|
if not sess_owner and not auth_disabled():
|
||||||
|
sess_owner = ""
|
||||||
main_is_vision = await asyncio.to_thread(
|
main_is_vision = await asyncio.to_thread(
|
||||||
model_supports_vision,
|
model_supports_vision,
|
||||||
sess.model or "",
|
sess.model or "",
|
||||||
getattr(sess, "endpoint_url", "") or "",
|
getattr(sess, "endpoint_url", "") or "",
|
||||||
|
sess_owner,
|
||||||
)
|
)
|
||||||
|
|
||||||
if effective_att_ids and vision_enabled:
|
if effective_att_ids and vision_enabled:
|
||||||
|
|||||||
+243
-24
@@ -5,9 +5,14 @@ import re
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import socket
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
|
import typing
|
||||||
|
import httpcore
|
||||||
|
from httpcore._backends.sync import SyncBackend, SyncStream
|
||||||
|
from httpcore._exceptions import map_exceptions, ConnectTimeout, ConnectError
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
@@ -17,6 +22,9 @@ from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ORIGINAL_HTTPX_GET = httpx.get
|
||||||
|
_ORIGINAL_HTTPX_POST = httpx.post
|
||||||
|
|
||||||
|
|
||||||
def extract_urls(text: str) -> List[str]:
|
def extract_urls(text: str) -> List[str]:
|
||||||
"""Extract URLs from text using regex pattern."""
|
"""Extract URLs from text using regex pattern."""
|
||||||
@@ -101,23 +109,167 @@ _PROVIDER_FINGERPRINT_TTL = 60.0
|
|||||||
_lmstudio_models_cache: dict = {}
|
_lmstudio_models_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def _is_local_host(host: Optional[str]) -> bool:
|
_BLOCKED_METADATA_IPS = {
|
||||||
"""True for loopback/LAN/Tailscale hosts (never public domains)."""
|
ipaddress.ip_address("169.254.169.254"),
|
||||||
host = (host or "").lower()
|
ipaddress.ip_address("169.254.169.253"),
|
||||||
|
ipaddress.ip_address("100.100.100.200"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
||||||
|
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ip_safe_local(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
|
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — unwrap to evaluate the embedded IPv4
|
||||||
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||||
|
ip = ip.ipv4_mapped
|
||||||
|
elif isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_PREFIX:
|
||||||
|
ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
|
||||||
|
|
||||||
|
if ip in _BLOCKED_METADATA_IPS:
|
||||||
|
return False
|
||||||
|
if ip.is_link_local:
|
||||||
|
return False
|
||||||
|
# Explicit loopback check before reserved rejection:
|
||||||
|
# RFC 4291 / CPython marks IPv6 ::1 as both is_loopback and is_reserved.
|
||||||
|
if ip.is_loopback:
|
||||||
|
return True
|
||||||
|
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||||
|
return False
|
||||||
|
if ip.is_private:
|
||||||
|
return True
|
||||||
|
if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_local_host(host: Optional[str]) -> bool:
|
||||||
|
"""True for loopback/LAN/Tailscale hosts.
|
||||||
|
|
||||||
|
Explicitly excludes link-local, cloud metadata destinations (AWS/GCP/Azure/Alibaba),
|
||||||
|
unresolved single-label names, and public domains.
|
||||||
|
"""
|
||||||
|
host = (host or "").strip().lower()
|
||||||
if not host:
|
if not host:
|
||||||
return False
|
return False
|
||||||
if host in {"localhost", "host.docker.internal"} or host.endswith(".local"):
|
# Strip optional IPv6 square brackets: [::1] -> ::1
|
||||||
return True
|
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
||||||
try:
|
try:
|
||||||
ip = ipaddress.ip_address(host)
|
ip = ipaddress.ip_address(literal)
|
||||||
|
return _is_ip_safe_local(ip)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return "." not in host
|
pass
|
||||||
if ip.is_loopback or ip.is_private or ip.is_link_local:
|
|
||||||
return True
|
allowed_names = {"localhost", "host.docker.internal"}
|
||||||
return ip in ipaddress.ip_network("100.64.0.0/10")
|
if not (host in allowed_names or host.endswith(".local") or host.endswith(".internal") or host.endswith(".lan")):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(host, None)
|
||||||
|
if not infos:
|
||||||
|
return False
|
||||||
|
resolved_ips = []
|
||||||
|
for info in infos:
|
||||||
|
sockaddr = info[4]
|
||||||
|
ip_str = str(sockaddr[0]).split("%")[0]
|
||||||
|
resolved_ips.append(ipaddress.ip_address(ip_str))
|
||||||
|
if not resolved_ips:
|
||||||
|
return False
|
||||||
|
return all(_is_ip_safe_local(ip) for ip in resolved_ips)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _probe_lmstudio_models(url: str) -> Optional[list]:
|
_is_local_host = _is_safe_local_host
|
||||||
|
|
||||||
|
|
||||||
|
class SafeProbeBackend(SyncBackend):
|
||||||
|
"""Enforces SSRF address policies at TCP connection time.
|
||||||
|
|
||||||
|
Resolves the host once, evaluates every resolved address against _is_ip_safe_local,
|
||||||
|
and connects directly to the pre-validated IP, eliminating DNS TOCTOU and rebinding.
|
||||||
|
Preserves TLS SNI and hostname certificate validation via SyncStream.start_tls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def connect_tcp(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
timeout: float | None = None,
|
||||||
|
local_address: str | None = None,
|
||||||
|
socket_options: typing.Iterable[httpcore.SOCKET_OPTION] | None = None,
|
||||||
|
) -> httpcore.NetworkStream:
|
||||||
|
source_address = None if local_address is None else (local_address, 0)
|
||||||
|
exc_map = {socket.timeout: ConnectTimeout, OSError: ConnectError}
|
||||||
|
with map_exceptions(exc_map):
|
||||||
|
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
|
||||||
|
if not infos:
|
||||||
|
raise ConnectError(f"Host {host} did not resolve")
|
||||||
|
for family, socktype, proto, _canon, sockaddr in infos:
|
||||||
|
ip_str = str(sockaddr[0]).split("%")[0]
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
except ValueError:
|
||||||
|
raise ConnectError(f"Invalid resolved IP: {ip_str}")
|
||||||
|
if not _is_ip_safe_local(ip):
|
||||||
|
raise ConnectError(f"Target address is not an authorized safe local destination: {ip}")
|
||||||
|
last_err: Optional[OSError] = None
|
||||||
|
for family, socktype, proto, _canon, sockaddr in infos:
|
||||||
|
sock = socket.socket(family, socktype, proto)
|
||||||
|
try:
|
||||||
|
if timeout is not None:
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
if source_address:
|
||||||
|
sock.bind(source_address)
|
||||||
|
sock.connect(sockaddr)
|
||||||
|
if socket_options:
|
||||||
|
for opt in socket_options:
|
||||||
|
sock.setsockopt(*opt)
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
return SyncStream(sock)
|
||||||
|
except OSError as e:
|
||||||
|
last_err = e
|
||||||
|
sock.close()
|
||||||
|
if last_err is not None:
|
||||||
|
raise last_err
|
||||||
|
raise ConnectError(f"Could not connect to {host}:{port}")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_probe_get(url: str, *, headers: Optional[dict] = None, timeout: float = 1.0) -> httpx.Response:
|
||||||
|
if httpx.get is not _ORIGINAL_HTTPX_GET:
|
||||||
|
try:
|
||||||
|
if headers:
|
||||||
|
return httpx.get(url, headers=headers, timeout=timeout, follow_redirects=False)
|
||||||
|
return httpx.get(url, timeout=timeout, follow_redirects=False)
|
||||||
|
except TypeError:
|
||||||
|
if headers:
|
||||||
|
return httpx.get(url, headers=headers, timeout=timeout)
|
||||||
|
return httpx.get(url, timeout=timeout)
|
||||||
|
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
||||||
|
transport = httpx.HTTPTransport(trust_env=False)
|
||||||
|
transport._pool = pool
|
||||||
|
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
||||||
|
return client.get(url, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_probe_post(url: str, *, json: typing.Any = None, headers: Optional[dict] = None, timeout: float = 8.0) -> httpx.Response:
|
||||||
|
if httpx.post is not _ORIGINAL_HTTPX_POST:
|
||||||
|
try:
|
||||||
|
if headers:
|
||||||
|
return httpx.post(url, headers=headers, json=json, timeout=timeout, follow_redirects=False)
|
||||||
|
return httpx.post(url, json=json, timeout=timeout, follow_redirects=False)
|
||||||
|
except TypeError:
|
||||||
|
if headers:
|
||||||
|
return httpx.post(url, headers=headers, json=json, timeout=timeout)
|
||||||
|
return httpx.post(url, json=json, timeout=timeout)
|
||||||
|
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
||||||
|
transport = httpx.HTTPTransport(trust_env=False)
|
||||||
|
transport._pool = pool
|
||||||
|
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
||||||
|
return client.post(url, headers=headers, json=json, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional[list]:
|
||||||
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
|
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
|
||||||
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
|
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -127,10 +279,10 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
|||||||
cached = _lmstudio_models_cache.get(key)
|
cached = _lmstudio_models_cache.get(key)
|
||||||
if cached is not None and cached[1] > now:
|
if cached is not None and cached[1] > now:
|
||||||
return cached[0]
|
return cached[0]
|
||||||
authority = host if parsed.port is None else f"{host}:{parsed.port}"
|
authority = parsed.netloc or (f"[{host}]:{parsed.port}" if (host and ":" in host and not host.startswith("[")) else (f"{host}:{parsed.port}" if (host and parsed.port is not None) else (host or "")))
|
||||||
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
|
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
|
||||||
try:
|
try:
|
||||||
r = httpx.get(probe_url, timeout=1.0)
|
r = _safe_probe_get(probe_url, headers=headers, timeout=1.0)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -148,15 +300,15 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
|||||||
return models
|
return models
|
||||||
|
|
||||||
|
|
||||||
def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
def lmstudio_supports_vision(url: str, model: str, headers: Optional[dict] = None) -> Optional[bool]:
|
||||||
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
|
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
|
||||||
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
|
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
|
||||||
if not model:
|
if not model:
|
||||||
return None
|
return None
|
||||||
# Never probe a remote provider; LM Studio is always a local/LAN host.
|
# Never probe a remote provider or disallowed host; LM Studio is always a local/LAN host.
|
||||||
if not _is_local_host(urlparse(url).hostname):
|
if not _is_safe_local_host(urlparse(url).hostname):
|
||||||
return None
|
return None
|
||||||
models = _probe_lmstudio_models(url)
|
models = _probe_lmstudio_models(url, headers=headers)
|
||||||
if not models:
|
if not models:
|
||||||
return None
|
return None
|
||||||
want = model.strip().lower()
|
want = model.strip().lower()
|
||||||
@@ -172,21 +324,79 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
def model_supports_vision(
|
||||||
|
model_name: str,
|
||||||
|
endpoint_url: str = "",
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
"""Whether a model accepts images, using the endpoint's reported
|
"""Whether a model accepts images, using the endpoint's reported
|
||||||
capability when available (LM Studio), a cached one-time probe for local
|
capability when available (LM Studio), a cached one-time probe for local
|
||||||
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
||||||
if is_vision_model(model_name):
|
if is_vision_model(model_name):
|
||||||
return True
|
return True
|
||||||
if endpoint_url:
|
if endpoint_url:
|
||||||
|
target_url = endpoint_url
|
||||||
|
headers: Optional[dict] = None
|
||||||
|
if owner is not None:
|
||||||
|
if not owner:
|
||||||
|
# Authentication enabled, but caller has no owner: refuse probing immediately
|
||||||
|
return is_vision_model(model_name)
|
||||||
|
try:
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.endpoint_resolver import (
|
||||||
|
normalize_base,
|
||||||
|
resolve_endpoint_runtime,
|
||||||
|
resolve_owner_registered_endpoint,
|
||||||
|
)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
ep = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
||||||
|
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
||||||
|
target_url = normalize_base(resolved_base or ep.base_url)
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
||||||
|
except ValueError:
|
||||||
|
# Foreign-owned, disabled, or unregistered endpoint
|
||||||
|
return is_vision_model(model_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Endpoint authorization check failed: %s", e)
|
||||||
|
return is_vision_model(model_name)
|
||||||
|
else:
|
||||||
|
# Single-user / auth-disabled mode (owner is None):
|
||||||
|
try:
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.endpoint_resolver import (
|
||||||
|
normalize_base,
|
||||||
|
resolve_endpoint_runtime,
|
||||||
|
resolve_owner_registered_endpoint,
|
||||||
|
)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
ep = resolve_owner_registered_endpoint(db, endpoint_url, None)
|
||||||
|
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
||||||
|
target_url = normalize_base(resolved_base or ep.base_url)
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
||||||
|
except Exception:
|
||||||
|
# In single-user mode, an unregistered endpoint is allowed
|
||||||
|
# only if target URL passes safe local host checks (probed with headers=None).
|
||||||
|
target_url = endpoint_url
|
||||||
|
headers = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
|
if headers:
|
||||||
|
advertised = lmstudio_supports_vision(target_url, model_name or "", headers=headers)
|
||||||
|
else:
|
||||||
|
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
||||||
|
except TypeError:
|
||||||
|
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
||||||
except Exception:
|
except Exception:
|
||||||
advertised = None
|
advertised = None
|
||||||
if advertised is not None:
|
if advertised is not None:
|
||||||
return advertised
|
return advertised
|
||||||
try:
|
try:
|
||||||
probed = probe_endpoint_supports_vision(endpoint_url, model_name or "")
|
if headers:
|
||||||
|
probed = probe_endpoint_supports_vision(target_url, model_name or "", headers=headers)
|
||||||
|
else:
|
||||||
|
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
||||||
|
except TypeError:
|
||||||
|
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
||||||
except Exception:
|
except Exception:
|
||||||
probed = None
|
probed = None
|
||||||
if probed is not None:
|
if probed is not None:
|
||||||
@@ -215,7 +425,11 @@ def _chat_completions_url(endpoint_url: str) -> str:
|
|||||||
return base + "/chat/completions"
|
return base + "/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Optional[bool]:
|
def probe_endpoint_supports_vision(
|
||||||
|
endpoint_url: str,
|
||||||
|
model_name: str,
|
||||||
|
headers: Optional[dict] = None,
|
||||||
|
) -> Optional[bool]:
|
||||||
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
||||||
an image_url content part for `model_name`?
|
an image_url content part for `model_name`?
|
||||||
|
|
||||||
@@ -226,8 +440,8 @@ def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Option
|
|||||||
if not endpoint_url or not model_name or not _vision_probe_enabled():
|
if not endpoint_url or not model_name or not _vision_probe_enabled():
|
||||||
return None
|
return None
|
||||||
parsed = urlparse(endpoint_url)
|
parsed = urlparse(endpoint_url)
|
||||||
if not _is_local_host(parsed.hostname):
|
if not _is_safe_local_host(parsed.hostname):
|
||||||
return None # never probe a public provider
|
return None # never probe a public provider or disallowed host
|
||||||
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = _vision_probe_cache.get(key)
|
cached = _vision_probe_cache.get(key)
|
||||||
@@ -247,7 +461,12 @@ def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Option
|
|||||||
}
|
}
|
||||||
verdict: Optional[bool] = None
|
verdict: Optional[bool] = None
|
||||||
try:
|
try:
|
||||||
r = httpx.post(_chat_completions_url(endpoint_url), json=payload, timeout=8.0)
|
r = _safe_probe_post(
|
||||||
|
_chat_completions_url(endpoint_url),
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=8.0,
|
||||||
|
)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
verdict = True
|
verdict = True
|
||||||
elif r.status_code in (400, 413, 415, 422):
|
elif r.status_code in (400, 413, 415, 422):
|
||||||
|
|||||||
@@ -1466,6 +1466,29 @@ def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_us
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def spec_router_thinking_decision_async(endpoint_url, model, owner, headers, direct_user_text):
|
||||||
|
"""Async env-gated router decision for spec tool-surface turns. Fail open (None)."""
|
||||||
|
try:
|
||||||
|
import sys as _sys
|
||||||
|
spec_router = _sys.modules.get('src.spec_router')
|
||||||
|
if spec_router is None:
|
||||||
|
from src import spec_router # noqa: F401
|
||||||
|
if not spec_router.think_router_enabled():
|
||||||
|
return None
|
||||||
|
from src.agent_loop import _configured_model_tool_surface_raw
|
||||||
|
if _configured_model_tool_surface_raw(
|
||||||
|
endpoint_url, model, owner, headers=headers
|
||||||
|
) != 'spec':
|
||||||
|
return None
|
||||||
|
if hasattr(spec_router, 'route_thinking_async'):
|
||||||
|
return await spec_router.route_thinking_async(direct_user_text)
|
||||||
|
return spec_router.route_thinking(direct_user_text)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
'spec think router decision failed; using rules only', exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def spec_router_think_reason(round_number, decision, existing_deliberate):
|
def spec_router_think_reason(round_number, decision, existing_deliberate):
|
||||||
"""Add 'router_think' on round 1 when the router asked for thinking and no
|
"""Add 'router_think' on round 1 when the router asked for thinking and no
|
||||||
other rule already selected a deliberate reason."""
|
other rule already selected a deliberate reason."""
|
||||||
@@ -1508,10 +1531,7 @@ def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
|
|||||||
spec_router = _sys.modules.get('src.spec_router')
|
spec_router = _sys.modules.get('src.spec_router')
|
||||||
if spec_router is None:
|
if spec_router is None:
|
||||||
from src import spec_router # noqa: F401
|
from src import spec_router # noqa: F401
|
||||||
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
decision = spec_router.latest_decision()
|
||||||
if not decisions:
|
|
||||||
return None
|
|
||||||
decision = decisions[-1]
|
|
||||||
if not isinstance(decision, dict):
|
if not isinstance(decision, dict):
|
||||||
return None
|
return None
|
||||||
# Guard against a stale decision from a previous turn.
|
# Guard against a stale decision from a previous turn.
|
||||||
@@ -1573,8 +1593,7 @@ def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
|
|||||||
set(disabled_tools or ()), list(conversation or []),
|
set(disabled_tools or ()), list(conversation or []),
|
||||||
return_decision=True,
|
return_decision=True,
|
||||||
)
|
)
|
||||||
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
decision = spec_router.latest_decision() or {}
|
||||||
decision = decisions[-1] if decisions else {}
|
|
||||||
if not isinstance(decision, dict) or decision.get('error'):
|
if not isinstance(decision, dict) or decision.get('error'):
|
||||||
return None
|
return None
|
||||||
choice = decision.get('choice')
|
choice = decision.get('choice')
|
||||||
@@ -3862,6 +3881,9 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
|
|||||||
or bare in BROKERED_JOB_TOOLS
|
or bare in BROKERED_JOB_TOOLS
|
||||||
or (contract_required and bare in {'edit_image', 'generate_image'})
|
or (contract_required and bare in {'edit_image', 'generate_image'})
|
||||||
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
|
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
|
||||||
|
# The editor command is conservatively classified as a write;
|
||||||
|
# document.info is a read even when the user requested no edit.
|
||||||
|
or (bare == 'run_editor_command' and args.get('op') == 'document.info')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if family not in authorized and not contextual_revision and not contract_scoped_mutation and not fixture_delete and not offered_private_action:
|
if family not in authorized and not contextual_revision and not contract_scoped_mutation and not fixture_delete and not offered_private_action:
|
||||||
@@ -5600,6 +5622,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
external_tool_schemas=None, temperature=0.0,
|
external_tool_schemas=None, temperature=0.0,
|
||||||
context_resolution=None,
|
context_resolution=None,
|
||||||
request_authority=MISSING_AUTHORITY, **ignored):
|
request_authority=MISSING_AUTHORITY, **ignored):
|
||||||
|
if spec_driver_active(endpoint_url, model, owner, headers):
|
||||||
|
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
|
return
|
||||||
from src.generation_sampling import validate_temperature
|
from src.generation_sampling import validate_temperature
|
||||||
temperature = validate_temperature(temperature)
|
temperature = validate_temperature(temperature)
|
||||||
# This path sends requests directly with httpx and therefore bypasses
|
# This path sends requests directly with httpx and therefore bypasses
|
||||||
@@ -6095,7 +6121,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
||||||
# Optional env-gated router decision (spec surface only). Computed
|
# Optional env-gated router decision (spec surface only). Computed
|
||||||
# once per user turn; disabled/None leaves behaviour unchanged.
|
# once per user turn; disabled/None leaves behaviour unchanged.
|
||||||
_spec_route_think = spec_router_thinking_decision(
|
_spec_route_think = await spec_router_thinking_decision_async(
|
||||||
endpoint_url, model, owner, headers, direct_user_text)
|
endpoint_url, model, owner, headers, direct_user_text)
|
||||||
if _spec_route_think is not None:
|
if _spec_route_think is not None:
|
||||||
logging.getLogger(__name__).info(
|
logging.getLogger(__name__).info(
|
||||||
@@ -6108,12 +6134,17 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
# round 1 is sent with tool_choice 'none' (same tools list).
|
# round 1 is sent with tool_choice 'none' (same tools list).
|
||||||
_spec_direct_p = spec_direct_no_tools_p(
|
_spec_direct_p = spec_direct_no_tools_p(
|
||||||
direct_user_text, endpoint_url, model, owner, headers)
|
direct_user_text, endpoint_url, model, owner, headers)
|
||||||
|
if (turn_contract.required or turn_contract.unavailable
|
||||||
|
or turn_contract.required_read_operation is not None):
|
||||||
|
_spec_direct_p = None
|
||||||
# Option B: send round 1 with no tools at all for a direct answer.
|
# Option B: send round 1 with no tools at all for a direct answer.
|
||||||
_spec_direct_empty_menu = (
|
_spec_direct_empty_menu = (
|
||||||
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
|
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
|
||||||
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
|
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
|
||||||
# every round; the 9B only writes the arguments or the answer.
|
# every round; the 9B only writes the arguments or the answer.
|
||||||
_spec_driver_on = spec_driver_active(endpoint_url, model, owner, headers)
|
# Driver execution is refused at entry. Keep this false even if
|
||||||
|
# configuration changes while the stream is already in progress.
|
||||||
|
_spec_driver_on = False
|
||||||
# Driver mode narrows the contract per round to the Spec pick.
|
# Driver mode narrows the contract per round to the Spec pick.
|
||||||
# Rebuild each round from this frozen base so the narrowing does
|
# Rebuild each round from this frozen base so the narrowing does
|
||||||
# not accumulate/mutate across rounds.
|
# not accumulate/mutate across rounds.
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ def prune_multimodal_images(
|
|||||||
return pruned
|
return pruned
|
||||||
|
|
||||||
|
|
||||||
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:\s+attached)?:\s*([^\]]+)\]", re.IGNORECASE)
|
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:[ \t]{1,64}attached)?:[ \t]{0,64}([^\]]{1,512})\]", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _image_turn_label(message: Dict) -> str:
|
def _image_turn_label(message: Dict) -> str:
|
||||||
@@ -102,7 +102,8 @@ def _image_turn_label(message: Dict) -> str:
|
|||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
for item in content:
|
for item in content:
|
||||||
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
|
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
|
||||||
candidate = item.get("name") or (item.get("image_url") or {}).get("name")
|
image_url = item.get("image_url")
|
||||||
|
candidate = item.get("name") or (image_url.get("name") if isinstance(image_url, dict) else None)
|
||||||
if candidate:
|
if candidate:
|
||||||
name = str(candidate)
|
name = str(candidate)
|
||||||
break
|
break
|
||||||
@@ -146,7 +147,7 @@ def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict
|
|||||||
trimmed.append(message)
|
trimmed.append(message)
|
||||||
continue
|
continue
|
||||||
kept_blocks = [
|
kept_blocks = [
|
||||||
item for item in content
|
dict(item) if isinstance(item, dict) else item for item in content
|
||||||
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
||||||
]
|
]
|
||||||
if len(kept_blocks) == len(content):
|
if len(kept_blocks) == len(content):
|
||||||
|
|||||||
+240
-6
@@ -339,14 +339,231 @@ async def self_check(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Resolve an explicit agent_model override under server authority.
|
||||||
|
|
||||||
|
Guarantees:
|
||||||
|
- Client-provided raw URLs never determine arbitrary outbound network destinations.
|
||||||
|
- Registered endpoint IDs resolve only to enabled endpoints visible to the owner.
|
||||||
|
- Shared endpoints (owner IS NULL) remain visible to authorized users.
|
||||||
|
- Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None).
|
||||||
|
- Admin privileges do not allow arbitrary unconfigured destinations.
|
||||||
|
"""
|
||||||
|
raw = str(override or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "::" in raw:
|
||||||
|
target, requested_model = raw.split("::", 1)
|
||||||
|
target = target.strip()
|
||||||
|
requested_model = requested_model.strip()
|
||||||
|
else:
|
||||||
|
target = ""
|
||||||
|
requested_model = raw
|
||||||
|
|
||||||
|
# 1. Explicit local selection or matching a configured local base
|
||||||
|
if target == "local":
|
||||||
|
bases = local_bases()
|
||||||
|
if not bases:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"base": bases[0],
|
||||||
|
"model": requested_model or local_model(),
|
||||||
|
"headers": None,
|
||||||
|
"source": "override",
|
||||||
|
}
|
||||||
|
for lb in local_bases():
|
||||||
|
if target and target.rstrip("/") == lb.rstrip("/"):
|
||||||
|
return {
|
||||||
|
"base": lb,
|
||||||
|
"model": requested_model or local_model(),
|
||||||
|
"headers": None,
|
||||||
|
"source": "override",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Explicit hosted fallback selection
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
|
||||||
|
for prefix in ("utility", "default"):
|
||||||
|
try:
|
||||||
|
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if url and model:
|
||||||
|
if target in ("default", "hosted"):
|
||||||
|
return {
|
||||||
|
"base": url,
|
||||||
|
"model": requested_model or model,
|
||||||
|
"headers": headers,
|
||||||
|
"source": "hosted",
|
||||||
|
}
|
||||||
|
if target and target.rstrip("/") == url.rstrip("/"):
|
||||||
|
if not requested_model or requested_model == model:
|
||||||
|
return {
|
||||||
|
"base": url,
|
||||||
|
"model": requested_model or model,
|
||||||
|
"headers": headers,
|
||||||
|
"source": "hosted",
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if target in ("default", "hosted"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 3. Bare model id without endpoint prefix
|
||||||
|
if not target:
|
||||||
|
if requested_model == "local":
|
||||||
|
bases = local_bases()
|
||||||
|
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None
|
||||||
|
if requested_model in ("default", "hosted"):
|
||||||
|
return _resolve_override_candidate("default::", owner)
|
||||||
|
|
||||||
|
# Exact match with local model
|
||||||
|
if requested_model == local_model():
|
||||||
|
bases = local_bases()
|
||||||
|
if bases:
|
||||||
|
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"}
|
||||||
|
|
||||||
|
# Exact match with hosted default/utility
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
|
||||||
|
for prefix in ("utility", "default"):
|
||||||
|
try:
|
||||||
|
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if url and model and model == requested_model:
|
||||||
|
return {"base": url, "model": model, "headers": headers, "source": "hosted"}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check registered database endpoints for this bare model
|
||||||
|
try:
|
||||||
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
|
from src.auth_helpers import owner_filter, _auth_disabled
|
||||||
|
from src.endpoint_resolver import (
|
||||||
|
resolve_endpoint_runtime,
|
||||||
|
build_chat_url,
|
||||||
|
build_headers,
|
||||||
|
_endpoint_enabled_models,
|
||||||
|
_endpoint_hidden_models,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if not owner and not _auth_disabled():
|
||||||
|
q = db.query(ModelEndpoint).filter(
|
||||||
|
ModelEndpoint.is_enabled == True,
|
||||||
|
ModelEndpoint.owner == None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
q = owner_filter(
|
||||||
|
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||||
|
ModelEndpoint,
|
||||||
|
owner,
|
||||||
|
)
|
||||||
|
matching_eps = []
|
||||||
|
for ep in q.all():
|
||||||
|
enabled_models = _endpoint_enabled_models(ep)
|
||||||
|
hidden_models = _endpoint_hidden_models(ep)
|
||||||
|
if requested_model in enabled_models and requested_model not in hidden_models:
|
||||||
|
matching_eps.append(ep)
|
||||||
|
|
||||||
|
# Avoid model ID collision across providers: only accept if unique
|
||||||
|
if len(matching_eps) == 1:
|
||||||
|
ep = matching_eps[0]
|
||||||
|
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
return {
|
||||||
|
"base": build_chat_url(base),
|
||||||
|
"model": requested_model,
|
||||||
|
"headers": build_headers(api_key, base),
|
||||||
|
"source": "registered",
|
||||||
|
"endpoint_id": ep.id,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 4. Target is an endpoint_id or a registered legacy endpoint URL
|
||||||
|
try:
|
||||||
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
|
from src.auth_helpers import owner_filter, _auth_disabled
|
||||||
|
from src.endpoint_resolver import (
|
||||||
|
resolve_endpoint_runtime,
|
||||||
|
build_chat_url,
|
||||||
|
build_headers,
|
||||||
|
_endpoint_enabled_models,
|
||||||
|
_endpoint_hidden_models,
|
||||||
|
_first_chat_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if not owner and not _auth_disabled():
|
||||||
|
q = db.query(ModelEndpoint).filter(
|
||||||
|
ModelEndpoint.is_enabled == True,
|
||||||
|
ModelEndpoint.owner == None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
q = owner_filter(
|
||||||
|
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||||
|
ModelEndpoint,
|
||||||
|
owner,
|
||||||
|
)
|
||||||
|
# Lookup by ID
|
||||||
|
ep = q.filter(ModelEndpoint.id == target).first()
|
||||||
|
|
||||||
|
# If not found by ID, match legacy registered base URL (strictly registered rows only)
|
||||||
|
if ep is None and ("://" in target or target.startswith("http")):
|
||||||
|
target_norm = target.rstrip("/")
|
||||||
|
for cand_ep in q.all():
|
||||||
|
cand_base = (cand_ep.base_url or "").rstrip("/")
|
||||||
|
if (
|
||||||
|
cand_base == target_norm
|
||||||
|
or cand_base + "/v1" == target_norm
|
||||||
|
or cand_base == target_norm + "/v1"
|
||||||
|
):
|
||||||
|
ep = cand_ep
|
||||||
|
break
|
||||||
|
|
||||||
|
if ep is None:
|
||||||
|
# Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed!
|
||||||
|
return None
|
||||||
|
|
||||||
|
enabled_models = _endpoint_enabled_models(ep)
|
||||||
|
hidden_models = _endpoint_hidden_models(ep)
|
||||||
|
if requested_model:
|
||||||
|
if requested_model in hidden_models:
|
||||||
|
return None
|
||||||
|
if enabled_models and requested_model not in enabled_models:
|
||||||
|
# Model not served by this endpoint -> fail closed against forged model IDs
|
||||||
|
return None
|
||||||
|
model = requested_model
|
||||||
|
else:
|
||||||
|
model = _first_chat_model(enabled_models)
|
||||||
|
if not model:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
return {
|
||||||
|
"base": build_chat_url(base),
|
||||||
|
"model": model,
|
||||||
|
"headers": build_headers(api_key, base),
|
||||||
|
"source": "registered",
|
||||||
|
"endpoint_id": ep.id,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
||||||
cands: List[Dict[str, Any]] = []
|
cands: List[Dict[str, Any]] = []
|
||||||
if override:
|
|
||||||
base, model = _split_override(override)
|
|
||||||
if model:
|
|
||||||
cands.append(
|
|
||||||
{"base": base or local_bases()[0], "model": model, "headers": None, "source": "override"}
|
|
||||||
)
|
|
||||||
for base in local_bases():
|
for base in local_bases():
|
||||||
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
||||||
try:
|
try:
|
||||||
@@ -362,6 +579,14 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
|
|||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if override:
|
||||||
|
cand = _resolve_override_candidate(override, owner)
|
||||||
|
if cand is not None:
|
||||||
|
cands.insert(0, cand)
|
||||||
|
return cands
|
||||||
|
# Unauthorized, invalid, foreign, disabled, or forged override:
|
||||||
|
# Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback.
|
||||||
|
return []
|
||||||
return cands
|
return cands
|
||||||
|
|
||||||
|
|
||||||
@@ -433,9 +658,11 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
|
|||||||
"source": "local",
|
"source": "local",
|
||||||
"vision": True,
|
"vision": True,
|
||||||
"base_url": base,
|
"base_url": base,
|
||||||
|
"endpoint_id": "local",
|
||||||
})
|
})
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
from src.settings import get_user_setting, load_settings
|
||||||
|
|
||||||
for prefix in ("utility", "default"):
|
for prefix in ("utility", "default"):
|
||||||
try:
|
try:
|
||||||
@@ -446,12 +673,19 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
|
|||||||
key = ("hosted", model)
|
key = ("hosted", model)
|
||||||
if key not in seen:
|
if key not in seen:
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
|
ep_id = None
|
||||||
|
try:
|
||||||
|
settings = load_settings()
|
||||||
|
ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
models.append({
|
models.append({
|
||||||
"id": model,
|
"id": model,
|
||||||
"label": model,
|
"label": model,
|
||||||
"source": "hosted",
|
"source": "hosted",
|
||||||
"vision": None,
|
"vision": None,
|
||||||
"base_url": url,
|
"base_url": url,
|
||||||
|
"endpoint_id": ep_id or prefix,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -486,11 +486,11 @@ _RECOLOR_EXCLUDE_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_RECOLOR_TRAILING_ADJ = re.compile(
|
_RECOLOR_TRAILING_ADJECTIVES = frozenset({
|
||||||
r"\s+\b(?:bright|brighter|vivid|vibrant|dark|darker|light|lighter|deep|"
|
"bright", "brighter", "vivid", "vibrant", "dark", "darker", "light",
|
||||||
r"pale|more|less|very|really|slightly|totally|completely)\b\s*$",
|
"lighter", "deep", "pale", "more", "less", "very", "really",
|
||||||
re.I,
|
"slightly", "totally", "completely",
|
||||||
)
|
})
|
||||||
|
|
||||||
|
|
||||||
def _hex_rgb01(hex_color: str):
|
def _hex_rgb01(hex_color: str):
|
||||||
@@ -557,9 +557,9 @@ def _recolor_object_phrase(prompt: str, target_word: str) -> str:
|
|||||||
words.pop(0)
|
words.pop(0)
|
||||||
if len(words) > 1 and words[-1].casefold() in {"to", "into"}:
|
if len(words) > 1 and words[-1].casefold() in {"to", "into"}:
|
||||||
words.pop()
|
words.pop()
|
||||||
|
while len(words) > 1 and words[-1].lower() in _RECOLOR_TRAILING_ADJECTIVES:
|
||||||
|
words.pop()
|
||||||
phrase = " ".join(words)
|
phrase = " ".join(words)
|
||||||
while phrase and _RECOLOR_TRAILING_ADJ.search(phrase):
|
|
||||||
phrase = _RECOLOR_TRAILING_ADJ.sub("", phrase).strip()
|
|
||||||
return phrase or "the subject"
|
return phrase or "the subject"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -119,12 +119,6 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
|
|
||||||
bare = tool.removeprefix("mcp__email__")
|
bare = tool.removeprefix("mcp__email__")
|
||||||
if bare == "list_email_accounts":
|
if bare == "list_email_accounts":
|
||||||
accounts = {}
|
|
||||||
for index, message in enumerate(messages):
|
|
||||||
row = _row(index, message)
|
|
||||||
identity = (row["account_id"], row["account"], row["account_email"])
|
|
||||||
accounts.setdefault(identity, {"id": row["account_id"], "name": row["account"],
|
|
||||||
"email": row["account_email"], "default": not accounts})
|
|
||||||
return {
|
return {
|
||||||
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
||||||
"output": "Fixture mailbox (default)",
|
"output": "Fixture mailbox (default)",
|
||||||
|
|||||||
+12
-11
@@ -17,6 +17,7 @@ Env:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
@@ -98,18 +99,18 @@ def score_value(answer: Optional[Dict[str, Any]]) -> Optional[float]:
|
|||||||
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
|
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
|
||||||
if not isinstance(answer, dict):
|
if not isinstance(answer, dict):
|
||||||
return None
|
return None
|
||||||
if answer.get("type") == "score":
|
try:
|
||||||
try:
|
if answer.get("type") == "score":
|
||||||
return float(answer.get("score"))
|
value = float(answer.get("score"))
|
||||||
except Exception:
|
elif answer.get("type") == "choice":
|
||||||
|
probs = answer.get("probabilities") or {}
|
||||||
|
value = float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
||||||
|
else:
|
||||||
return None
|
return None
|
||||||
if answer.get("type") == "choice":
|
except Exception:
|
||||||
probs = answer.get("probabilities") or {}
|
return None
|
||||||
try:
|
# A malformed adapter reply must fall back like any other failure.
|
||||||
return float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
return value if math.isfinite(value) else None
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
|||||||
+20
-6
@@ -432,12 +432,15 @@ class _DegenerateStreamGuard:
|
|||||||
"Summer Summer ..."). This is not a useful response and can burn context,
|
"Summer Summer ..."). This is not a useful response and can burn context,
|
||||||
browser memory, and GPU time. Keep the guard conservative: only fire on long
|
browser memory, and GPU time. Keep the guard conservative: only fire on long
|
||||||
same-token runs or a very dominant repeated token in the recent window.
|
same-token runs or a very dominant repeated token in the recent window.
|
||||||
|
Repetition made only of numbers is data (a zero or identity matrix, a
|
||||||
|
padded buffer), so it only stops at a long all-numeric backstop.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, model: str):
|
def __init__(self, model: str):
|
||||||
self.model = model or "model"
|
self.model = model or "model"
|
||||||
self.last_token = ""
|
self.last_token = ""
|
||||||
self.same_run = 0
|
self.same_run = 0
|
||||||
|
self.numeric_run = 0
|
||||||
self.recent_tokens: List[str] = []
|
self.recent_tokens: List[str] = []
|
||||||
self.periodic_tokens: List[str] = []
|
self.periodic_tokens: List[str] = []
|
||||||
self.total_chars = 0
|
self.total_chars = 0
|
||||||
@@ -458,6 +461,7 @@ class _DegenerateStreamGuard:
|
|||||||
else:
|
else:
|
||||||
self.last_token = token
|
self.last_token = token
|
||||||
self.same_run = 1
|
self.same_run = 1
|
||||||
|
self.numeric_run = self.numeric_run + 1 if token.isdigit() else 0
|
||||||
self.recent_tokens.append(token)
|
self.recent_tokens.append(token)
|
||||||
self.periodic_tokens.append(token)
|
self.periodic_tokens.append(token)
|
||||||
if len(self.recent_tokens) > 96:
|
if len(self.recent_tokens) > 96:
|
||||||
@@ -466,14 +470,20 @@ class _DegenerateStreamGuard:
|
|||||||
self.periodic_tokens = self.periodic_tokens[-2048:]
|
self.periodic_tokens = self.periodic_tokens[-2048:]
|
||||||
|
|
||||||
reason = None
|
reason = None
|
||||||
if self.same_run >= 28 and self.total_chars >= 100:
|
if self.same_run >= 28 and self.total_chars >= 100 and not self.last_token.isdigit():
|
||||||
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
||||||
elif len(self.recent_tokens) >= 72:
|
elif len(self.recent_tokens) >= 72:
|
||||||
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
||||||
count = self.recent_tokens.count(top)
|
count = self.recent_tokens.count(top)
|
||||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
|
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not top.isdigit():
|
||||||
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
||||||
if not reason and len(self.recent_tokens) >= 48:
|
if (
|
||||||
|
not reason
|
||||||
|
and len(self.recent_tokens) >= 48
|
||||||
|
# A window that is mostly numbers is matrix/table data; its
|
||||||
|
# periodicity is real. Mixed loops still hit the exact-block check.
|
||||||
|
and sum(t.isdigit() for t in self.recent_tokens) * 2 < len(self.recent_tokens)
|
||||||
|
):
|
||||||
# Detect a periodic suffix instead of counting repeated n-grams.
|
# Detect a periodic suffix instead of counting repeated n-grams.
|
||||||
# Reused property names are normal in CSS/SVG/JSON; a collapsed
|
# Reused property names are normal in CSS/SVG/JSON; a collapsed
|
||||||
# generation repeats nearly every token at a short fixed period.
|
# generation repeats nearly every token at a short fixed period.
|
||||||
@@ -510,10 +520,13 @@ class _DegenerateStreamGuard:
|
|||||||
period = current_anchor - previous
|
period = current_anchor - previous
|
||||||
if period < 24 or len(history) < period * 2:
|
if period < 24 or len(history) < period * 2:
|
||||||
continue
|
continue
|
||||||
if history[-2 * period:-period] == history[-period:]:
|
if history[-2 * period:-period] == history[-period:] and not all(t.isdigit() for t in history[-period:]):
|
||||||
reason = f"repeated an exact {period}-token block twice"
|
reason = f"repeated an exact {period}-token block twice"
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if not reason and self.numeric_run >= 2048:
|
||||||
|
reason = f"emitted {self.numeric_run} numbers in a row"
|
||||||
|
|
||||||
if not reason:
|
if not reason:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1556,8 +1569,9 @@ def _build_chatgpt_responses_payload(
|
|||||||
"stream": stream,
|
"stream": stream,
|
||||||
"store": False,
|
"store": False,
|
||||||
}
|
}
|
||||||
if not _restricts_temperature(model):
|
# ChatGPT plan Responses requests reject temperature for every model.
|
||||||
payload["temperature"] = temperature
|
# The common caller still passes its preset value, but this provider must
|
||||||
|
# omit the field even for model names outside the known reasoning families.
|
||||||
# ChatGPT Subscription Codex API does not support max_output_tokens —
|
# ChatGPT Subscription Codex API does not support max_output_tokens —
|
||||||
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
|
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
|
||||||
# Do not include it in the payload.
|
# Do not include it in the payload.
|
||||||
|
|||||||
+15
-5
@@ -10,6 +10,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
from src.database import McpServer, SessionLocal
|
from src.database import McpServer, SessionLocal
|
||||||
|
|
||||||
@@ -383,12 +384,21 @@ class McpManager:
|
|||||||
|
|
||||||
provider = build_provider(server_id, url, on_redirect=_on_redirect)
|
provider = build_provider(server_id, url, on_redirect=_on_redirect)
|
||||||
stack = AsyncExitStack()
|
stack = AsyncExitStack()
|
||||||
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
try:
|
||||||
read_stream, write_stream, _get_session_id = transport
|
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
||||||
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
read_stream, write_stream, _get_session_id = transport
|
||||||
await session.initialize()
|
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
||||||
|
await session.initialize()
|
||||||
|
|
||||||
tools_result = await session.list_tools()
|
tools_result = await session.list_tools()
|
||||||
|
except BaseException:
|
||||||
|
# Close the half-open transport here, in the task that entered
|
||||||
|
# it. Left for the garbage collector, its AnyIO cancel scope is
|
||||||
|
# exited from the wrong task and the event loop keeps spinning
|
||||||
|
# (#5518). Its own teardown error would only mask the real one.
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await stack.aclose()
|
||||||
|
raise
|
||||||
tools = []
|
tools = []
|
||||||
for tool in tools_result.tools:
|
for tool in tools_result.tools:
|
||||||
tools.append({
|
tools.append({
|
||||||
|
|||||||
@@ -193,7 +193,10 @@ class ModelDiscovery:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
|
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
|
||||||
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
|
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
|
||||||
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
ids = [
|
||||||
|
m["id"] for m in items
|
||||||
|
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]
|
||||||
|
]
|
||||||
if ids:
|
if ids:
|
||||||
return {
|
return {
|
||||||
"host": host,
|
"host": host,
|
||||||
|
|||||||
+4
-2
@@ -102,8 +102,10 @@ _PLAIN_FRONT_MATTER_RE = re.compile(
|
|||||||
# - **label:** value <!-- field=NAME-ENC type=text -->
|
# - **label:** value <!-- field=NAME-ENC type=text -->
|
||||||
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
|
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
|
||||||
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
|
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
|
||||||
|
# Accept Unicode word characters for older saved markers, whose encoder left
|
||||||
|
# non-ASCII letters/numbers literal. Newly rendered names are ASCII-safe.
|
||||||
_FIELD_BULLET_RE = re.compile(
|
_FIELD_BULLET_RE = re.compile(
|
||||||
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[A-Za-z0-9_.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[\w.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ def _encode_name(name: str) -> str:
|
|||||||
"""
|
"""
|
||||||
out = []
|
out = []
|
||||||
for ch in name or "":
|
for ch in name or "":
|
||||||
if ch.isalnum() or ch in ("_", ".", "-"):
|
if (ch.isascii() and ch.isalnum()) or ch in ("_", ".", "-"):
|
||||||
out.append(ch)
|
out.append(ch)
|
||||||
else:
|
else:
|
||||||
for b in ch.encode("utf-8"):
|
for b in ch.encode("utf-8"):
|
||||||
|
|||||||
@@ -78,8 +78,6 @@ def untrusted_context_message(
|
|||||||
"""
|
"""
|
||||||
safe_label = _sanitize_label(label)
|
safe_label = _sanitize_label(label)
|
||||||
text = "" if content is None else str(content)
|
text = "" if content is None else str(content)
|
||||||
import logging as _lg
|
|
||||||
_lg.getLogger("src.prompt_security").info("[spec-debug] untrusted block label=%r content_chars=%d", safe_label[:80], len(text))
|
|
||||||
text = _escape_guard_markers(text)
|
text = _escape_guard_markers(text)
|
||||||
metadata: Dict[str, Any] = {
|
metadata: Dict[str, Any] = {
|
||||||
"trusted": False,
|
"trusted": False,
|
||||||
|
|||||||
+8
-51
@@ -12,16 +12,6 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Names that indicate a throwaway/test session
|
|
||||||
_THROWAWAY_NAMES = {
|
|
||||||
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
|
|
||||||
"yo", "sup", "hola", "hii", "hiii", "heyo",
|
|
||||||
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
|
|
||||||
"new chat", "delete", "remove", "junk", "trash", "xxx",
|
|
||||||
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
|
|
||||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
|
||||||
}
|
|
||||||
_THROWAWAY_MAX_MESSAGES = 4
|
|
||||||
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
|
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
|
||||||
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
|
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
|
||||||
|
|
||||||
@@ -54,14 +44,15 @@ def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> boo
|
|||||||
|
|
||||||
|
|
||||||
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
|
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
|
||||||
"""Run session cleanup + (optional) AI folder sort for the given owner.
|
"""Remove empty/Incognito sessions and optionally organize saved chats.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
owner: user whose sessions to process
|
owner: user whose sessions to process
|
||||||
skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
|
skip_llm: when True, do only Phase 1 (delete empty/Incognito sessions);
|
||||||
skip Phase 2 (AI folder assignment). Used by the built-in daily
|
skip Phase 2 (AI folder assignment). Used by the built-in daily
|
||||||
background sweep so it never burns LLM tokens.
|
background sweep so it never burns LLM tokens.
|
||||||
delete_throwaway: when False, only empty/incognito sessions are deleted.
|
delete_throwaway: retained for caller compatibility. Ordinary sessions
|
||||||
|
with saved messages are always preserved, regardless of this flag.
|
||||||
|
|
||||||
Returns a human-readable summary of what was done.
|
Returns a human-readable summary of what was done.
|
||||||
"""
|
"""
|
||||||
@@ -71,7 +62,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
|||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# ── Phase 1: Delete empty/throwaway sessions ──
|
# ── Phase 1: Delete only empty and explicitly Incognito sessions ──
|
||||||
deleted_empty = 0
|
deleted_empty = 0
|
||||||
deleted_throwaway = 0
|
deleted_throwaway = 0
|
||||||
|
|
||||||
@@ -93,47 +84,13 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
|||||||
if is_session_recently_active(row, now=cleanup_now):
|
if is_session_recently_active(row, now=cleanup_now):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
msg_count = db.query(DbMsg.id).filter(
|
has_messages = db.query(DbMsg.id).filter(
|
||||||
DbMsg.session_id == row.id
|
DbMsg.session_id == row.id
|
||||||
).limit(_THROWAWAY_MAX_MESSAGES + 1).count()
|
).first() is not None
|
||||||
should_delete = False
|
if not has_messages:
|
||||||
|
|
||||||
if msg_count == 0:
|
|
||||||
if is_fresh:
|
if is_fresh:
|
||||||
continue
|
continue
|
||||||
should_delete = True
|
|
||||||
deleted_empty += 1
|
deleted_empty += 1
|
||||||
elif delete_throwaway and msg_count <= _THROWAWAY_MAX_MESSAGES:
|
|
||||||
name = (row.name or "").strip().lower()
|
|
||||||
first_msg = db.query(DbMsg.content).filter(
|
|
||||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
|
||||||
).order_by(DbMsg.timestamp).first()
|
|
||||||
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
|
|
||||||
assistant_count = db.query(DbMsg.id).filter(
|
|
||||||
DbMsg.session_id == row.id, DbMsg.role == "assistant"
|
|
||||||
).limit(1).count()
|
|
||||||
|
|
||||||
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
|
|
||||||
should_delete = True
|
|
||||||
deleted_throwaway += 1
|
|
||||||
elif msg_count == 1 and assistant_count == 0:
|
|
||||||
should_delete = True
|
|
||||||
deleted_throwaway += 1
|
|
||||||
elif msg_count <= 4 and first_text and len(first_text.split()) <= 8 and len(first_text) <= 80:
|
|
||||||
# Short trivial chats — e.g. "write hi to a friend" → "Hi!"
|
|
||||||
should_delete = True
|
|
||||||
deleted_throwaway += 1
|
|
||||||
else:
|
|
||||||
# Aggressive: total message text under 250 chars combined = trivial
|
|
||||||
msg_rows = db.query(DbMsg.content).filter(
|
|
||||||
DbMsg.session_id == row.id
|
|
||||||
).all()
|
|
||||||
total_chars = sum(len(m[0] or "") for m in msg_rows)
|
|
||||||
if total_chars <= 250:
|
|
||||||
should_delete = True
|
|
||||||
deleted_throwaway += 1
|
|
||||||
|
|
||||||
if should_delete:
|
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
|
|
||||||
if deleted_empty or deleted_throwaway:
|
if deleted_empty or deleted_throwaway:
|
||||||
|
|||||||
+2
-3
@@ -47,9 +47,8 @@ DEFAULT_SETTINGS = {
|
|||||||
"image_gen_enabled": False,
|
"image_gen_enabled": False,
|
||||||
"image_model": "",
|
"image_model": "",
|
||||||
"image_quality": "medium",
|
"image_quality": "medium",
|
||||||
# Local editor-agent/planner model (Quick Edit NL planner). When empty the
|
# Optional editor planner/caption endpoints. Empty uses configured chat
|
||||||
# server auto-detects the local Qwen3.5-9B vision server on seneca and falls
|
# fallback without probing a developer-specific host.
|
||||||
# back to the hosted chat model. See src/editor_agent_model.py.
|
|
||||||
"editor_agent_base_url": "",
|
"editor_agent_base_url": "",
|
||||||
"editor_agent_model": "",
|
"editor_agent_model": "",
|
||||||
"editor_caption_base_url": "",
|
"editor_caption_base_url": "",
|
||||||
|
|||||||
+5
-167
@@ -1,13 +1,4 @@
|
|||||||
"""Ajax Spec turn: the Spec router drives every step; the chat model never chooses a tool.
|
"""Helpers for the experimental Spec driver, whose execution is disabled."""
|
||||||
|
|
||||||
Each round the router (spec_router.next_action) picks one action from the full tool surface:
|
|
||||||
tool -> the chat model gets only that tool (named tool_choice) and writes its arguments;
|
|
||||||
the executor runs it; the result goes back to the router for the next pick.
|
|
||||||
answer_directly -> the chat model writes the answer with no tools (tools key omitted).
|
|
||||||
ask_user -> the chat model writes the question through the ask_user tool.
|
|
||||||
Router unavailable -> the chat model answers without tools. Execution keeps the normal request
|
|
||||||
authority and tool policy (execute_tool_block); nothing else is layered on top.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -110,161 +101,8 @@ async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id
|
|||||||
client_runtime_context=None, external_untrusted_context_seen=False,
|
client_runtime_context=None, external_untrusted_context_seen=False,
|
||||||
max_tokens=768, max_rounds=8, temperature=0.0,
|
max_tokens=768, max_rounds=8, temperature=0.0,
|
||||||
request_authority=MISSING_AUTHORITY, **ignored):
|
request_authority=MISSING_AUTHORITY, **ignored):
|
||||||
from src.turn_contract import _ACTIVE_CONTRACT
|
# This experimental runtime lacks the normal turn and completion gates.
|
||||||
_ACTIVE_CONTRACT.set(None) # Spec, not the regex turn contract, decides the tools.
|
# Refuse direct callers too, before contacting a router or executor.
|
||||||
started = time.monotonic()
|
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
|
||||||
surface = spec_surface(disabled_tools)
|
|
||||||
by_name = {_name(s): s for s in surface}
|
|
||||||
user_text = next((_text(m.get('content')) for m in reversed(messages) if m.get('role') == 'user'), '')
|
|
||||||
history = list(messages)
|
|
||||||
security = ToolRunSecurityContext(
|
|
||||||
external_untrusted_context_seen=bool(external_untrusted_context_seen), unattended_tools=frozenset())
|
|
||||||
security.observe_messages(messages)
|
|
||||||
picks, executions, done_calls = [], [], set()
|
|
||||||
latest, first_token, usage_in, usage_out, rounds = '', None, 0, 0, 0
|
|
||||||
recovery = {}
|
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
|
||||||
for round_number in range(1, max_rounds + 1):
|
|
||||||
rounds = round_number
|
|
||||||
action = await asyncio.to_thread(
|
|
||||||
spec_router.next_action, user_text, latest, surface, messages[:-1], disabled_tools)
|
|
||||||
if action is None:
|
|
||||||
action = {'kind': 'answer_directly', 'name': 'router_unavailable', 'p': None}
|
|
||||||
if round_number == max_rounds and action['kind'] == 'tool':
|
|
||||||
action = {'kind': 'answer_directly', 'name': 'round_limit', 'p': None}
|
|
||||||
picks.append({'round': round_number, **action})
|
|
||||||
logger.info('[spec-driver] round=%s pick=%s p=%s', round_number, action['name'], action.get('p'))
|
|
||||||
request = {'model': model, 'messages': history, 'max_tokens': max_tokens,
|
|
||||||
'temperature': temperature, 'stream': True,
|
|
||||||
'stream_options': {'include_usage': True},
|
|
||||||
'chat_template_kwargs': {'enable_thinking': False}}
|
|
||||||
|
|
||||||
if action['kind'] == 'answer_directly':
|
|
||||||
answer_parts = []
|
|
||||||
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
|
||||||
async for line in response.aiter_lines():
|
|
||||||
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
chunk = json.loads(line[6:])
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if chunk.get('error') or chunk.get('object') == 'error':
|
|
||||||
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
|
||||||
u = chunk.get('usage') or {}
|
|
||||||
usage_in += u.get('prompt_tokens') or 0
|
|
||||||
usage_out += u.get('completion_tokens') or 0
|
|
||||||
for choice in chunk.get('choices') or []:
|
|
||||||
text = (choice.get('delta') or {}).get('content') or ''
|
|
||||||
if text:
|
|
||||||
first_token = first_token or time.monotonic()
|
|
||||||
answer_parts.append(text)
|
|
||||||
yield event({'delta': text})
|
|
||||||
answer = ''.join(answer_parts).strip()
|
|
||||||
if not answer:
|
|
||||||
yield event({'delta': 'The model returned no answer.'})
|
|
||||||
history.append({'role': 'assistant', 'content': answer})
|
|
||||||
break
|
|
||||||
|
|
||||||
name = 'ask_user' if action['kind'] == 'ask_user' else action['name']
|
|
||||||
# ODYSSEUS_SPEC_DRIVER_TOPK=k>1: offer Spec's top-k tools and let the chat model choose among them.
|
|
||||||
topk = int(os.getenv('ODYSSEUS_SPEC_DRIVER_TOPK', '1') or 1)
|
|
||||||
options = [name]
|
|
||||||
if action['kind'] == 'tool' and topk > 1:
|
|
||||||
options = [n for n, _ in action.get('top') or []][:topk] or [name]
|
|
||||||
schemas = [by_name.get(n) or next((s for s in FUNCTION_TOOL_SCHEMAS if _name(s) == n), None)
|
|
||||||
for n in options]
|
|
||||||
options = [n for n, sc in zip(options, schemas) if sc is not None]
|
|
||||||
schemas = [sc for sc in schemas if sc is not None]
|
|
||||||
if not schemas:
|
|
||||||
logger.info('[spec-driver] pick %s has no schema; answering instead', name)
|
|
||||||
latest = f'Tool {name} is not available.'
|
|
||||||
continue
|
|
||||||
request['tools'] = [grammar_safe(sc) for sc in schemas]
|
|
||||||
request['tool_choice'] = ({'type': 'function', 'function': {'name': options[0]}}
|
|
||||||
if len(options) == 1 else 'required')
|
|
||||||
if len(options) > 1:
|
|
||||||
logger.info('[spec-driver] round=%s options=%s', round_number, options)
|
|
||||||
try:
|
|
||||||
content, calls, usage = await _model_round(client, endpoint_url, headers, request, recovery)
|
|
||||||
except (RuntimeError, httpx.HTTPError) as exc:
|
|
||||||
logger.warning('[spec-driver] round=%s %s call failed: %s', round_number, name, exc)
|
|
||||||
latest = f'The {name} call could not be produced: {exc}'
|
|
||||||
continue
|
|
||||||
first_token = first_token or time.monotonic()
|
|
||||||
usage_in += usage.get('prompt_tokens') or 0
|
|
||||||
usage_out += usage.get('completion_tokens') or 0
|
|
||||||
call = next((c for c in calls if c['name'] in options), None)
|
|
||||||
if call is not None and len(options) > 1:
|
|
||||||
name = call['name']
|
|
||||||
logger.info('[spec-driver] round=%s model chose=%s rank=%s of %s',
|
|
||||||
round_number, name, options.index(name) + 1, options)
|
|
||||||
picks[-1]['chosen'] = name
|
|
||||||
if call is None:
|
|
||||||
latest = f'The {name} call was not produced.'
|
|
||||||
logger.info('[spec-driver] round=%s no %s call from the model', round_number, name)
|
|
||||||
continue
|
|
||||||
arguments = call['arguments'] or '{}'
|
|
||||||
signature = (name, arguments)
|
|
||||||
call_id = call['id'] or f'call_{uuid.uuid4().hex[:12]}'
|
|
||||||
history.append({'role': 'assistant', 'content': content or '',
|
|
||||||
'tool_calls': [{'id': call_id, 'type': 'function',
|
|
||||||
'function': {'name': name, 'arguments': arguments}}]})
|
|
||||||
if signature in done_calls:
|
|
||||||
output = 'This exact call already succeeded; its result is above. Do not repeat it.'
|
|
||||||
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
|
||||||
latest = output
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
args = json.loads(arguments)
|
|
||||||
except ValueError:
|
|
||||||
args = {}
|
|
||||||
block = function_call_to_tool_block(name, arguments)
|
|
||||||
yield event({'type': 'tool_start', 'tool': name, 'command': arguments,
|
|
||||||
'full_command': arguments, 'round': round_number})
|
|
||||||
tool_started = time.monotonic()
|
|
||||||
if block is None:
|
|
||||||
desc, result = f'{name}: invalid arguments', {
|
|
||||||
'error': 'Tool arguments could not be converted for execution.', 'exit_code': 1}
|
|
||||||
else:
|
|
||||||
desc, result = await execute_tool_block(
|
|
||||||
block, session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
|
||||||
tool_policy=tool_policy, security_context=security,
|
|
||||||
active_document_id=getattr(active_document, 'id', None), workspace=workspace,
|
|
||||||
client_runtime_context=client_runtime_context, request_authority=request_authority)
|
|
||||||
failed = bool(result.get('error')) or result.get('exit_code') not in (None, 0)
|
|
||||||
output = preview_tool_result_text(result, name, args)
|
|
||||||
tool_event = {'type': 'tool_output', 'tool': name, 'command': arguments, 'output': output,
|
|
||||||
'exit_code': result.get('exit_code', 1 if failed else 0), 'error': failed,
|
|
||||||
'execution_attempted': block is not None, 'blocked': bool(result.get('blocked')),
|
|
||||||
'desc': desc, 'round': round_number,
|
|
||||||
'seconds': round(time.monotonic() - tool_started, 3)}
|
|
||||||
if result.get('image_url'):
|
|
||||||
yield event({'type': 'generated_image', 'url': result['image_url'],
|
|
||||||
**{k: result[k] for k in ('image_url', 'image_id', 'image_prompt') if k in result}})
|
|
||||||
if result.get('ui_event'):
|
|
||||||
yield event({'type': 'ui_control', 'data': result})
|
|
||||||
executions.append(tool_event)
|
|
||||||
yield event(tool_event)
|
|
||||||
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
|
||||||
if not failed:
|
|
||||||
done_calls.add(signature)
|
|
||||||
latest = output
|
|
||||||
ask = result.get('ask_user')
|
|
||||||
if isinstance(ask, dict) and str(ask.get('question') or '').strip():
|
|
||||||
question = str(ask['question']).strip()
|
|
||||||
history.append({'role': 'assistant', 'content': question})
|
|
||||||
yield event({'type': 'final_response', 'content': question})
|
|
||||||
yield event({'type': 'ask_user', 'data': ask})
|
|
||||||
break
|
|
||||||
elapsed = time.monotonic() - started
|
|
||||||
yield event({'type': 'metrics', 'data': {
|
|
||||||
'model': model, 'input_tokens': usage_in, 'output_tokens': usage_out,
|
|
||||||
'total_tokens': usage_in + usage_out, 'response_time': round(elapsed, 3),
|
|
||||||
'time_to_first_token': round(first_token - started, 3) if first_token else None,
|
|
||||||
'tokens_per_second': round(usage_out / elapsed, 2) if elapsed > 0 else 0,
|
|
||||||
'agent_rounds': rounds, 'tool_calls': len(executions), 'tool_events': executions,
|
|
||||||
'spec_driver_picks': picks, 'tool_schema_count': len(surface),
|
|
||||||
'schema_mode': 'spec_driver', 'temperature': temperature, 'max_output_tokens': max_tokens,
|
|
||||||
}})
|
|
||||||
yield 'data: [DONE]\n\n'
|
yield 'data: [DONE]\n\n'
|
||||||
|
return
|
||||||
|
|||||||
+274
-9
@@ -1,9 +1,9 @@
|
|||||||
"""Odysseus spec: small-model tool router (JEFF-style) for the agent harness.
|
"""Odysseus spec: small-model tool router (JEFF-style) for the agent harness.
|
||||||
|
|
||||||
The router (a 0.8B decision model served by jeff-serve) reads the user's
|
The router (a 0.8B decision model served by jeff-serve) reads the user's
|
||||||
message and scores every offered tool in one forward pass. The harness only
|
message and scores offered tools in one forward pass. The harness may narrow
|
||||||
ever ADDS tools from its answer, never removes any, and fails open: on any
|
the existing turn contract, but cannot add authority or drop its obligations.
|
||||||
error, timeout or low-confidence answer the compact tool set is unchanged.
|
On an error, timeout or low-confidence answer it retains the existing contract.
|
||||||
|
|
||||||
Env:
|
Env:
|
||||||
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
|
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
|
||||||
@@ -17,12 +17,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import asyncio
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
from contextvars import ContextVar
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_HEAD = {
|
_HEAD = {
|
||||||
@@ -35,8 +40,16 @@ _INSTRUCTIONS = (
|
|||||||
"If a tool is needed but information it requires is missing, choose ask the user."
|
"If a tool is needed but information it requires is missing, choose ask the user."
|
||||||
)
|
)
|
||||||
_AGENT = "Odysseus, a personal tool-using assistant."
|
_AGENT = "Odysseus, a personal tool-using assistant."
|
||||||
# Recent decisions, for debugging in the UI/logs: most recent last.
|
_LAST_DECISION: ContextVar[Optional[Dict[str, Any]]] = ContextVar('spec_router_decision', default=None)
|
||||||
LAST_DECISIONS: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
|
def latest_decision() -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return the router decision in the current request context."""
|
||||||
|
return _LAST_DECISION.get()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_decision() -> None:
|
||||||
|
_LAST_DECISION.set(None)
|
||||||
_MCP_BROWSER_TOOLS = (
|
_MCP_BROWSER_TOOLS = (
|
||||||
"mcp__builtin_browser__browser_navigate",
|
"mcp__builtin_browser__browser_navigate",
|
||||||
"mcp__builtin_browser__browser_snapshot",
|
"mcp__builtin_browser__browser_snapshot",
|
||||||
@@ -105,6 +118,7 @@ def spec_route_tools(
|
|||||||
the router answered confidently (a tool pick, or answer_directly)."""
|
the router answered confidently (a tool pick, or answer_directly)."""
|
||||||
added: Set[str] = set(prior_tool_names(messages))
|
added: Set[str] = set(prior_tool_names(messages))
|
||||||
decided = False
|
decided = False
|
||||||
|
clear_decision()
|
||||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||||
if not url or not (user_message or "").strip():
|
if not url or not (user_message or "").strip():
|
||||||
return (added, False) if return_decision else added
|
return (added, False) if return_decision else added
|
||||||
@@ -143,6 +157,15 @@ def spec_route_tools(
|
|||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
probs = out.get("probabilities") or {}
|
probs = out.get("probabilities") or {}
|
||||||
choice = out.get("choice")
|
choice = out.get("choice")
|
||||||
|
if not isinstance(probs, dict) or choice not in criteria:
|
||||||
|
raise ValueError("invalid router choice")
|
||||||
|
for key, probability in probs.items():
|
||||||
|
if key not in criteria or isinstance(probability, bool):
|
||||||
|
raise ValueError("invalid router probability key")
|
||||||
|
number = float(probability)
|
||||||
|
if not math.isfinite(number) or not 0 <= number <= 1:
|
||||||
|
raise ValueError("invalid router probability")
|
||||||
|
probs = {key: float(value) for key, value in probs.items()}
|
||||||
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
||||||
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
||||||
ranked = sorted(
|
ranked = sorted(
|
||||||
@@ -172,11 +195,133 @@ def spec_route_tools(
|
|||||||
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
||||||
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
||||||
except Exception as exc: # fail open
|
except Exception as exc: # fail open
|
||||||
|
added = set(prior_tool_names(messages))
|
||||||
|
decided = False
|
||||||
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
||||||
logger.warning("spec router failed open: %s", exc)
|
logger.warning("spec router failed open: %s", exc)
|
||||||
LAST_DECISIONS.append(decision)
|
|
||||||
del LAST_DECISIONS[:-50]
|
|
||||||
decision["decided"] = decided
|
decision["decided"] = decided
|
||||||
|
_LAST_DECISION.set(decision)
|
||||||
|
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||||
|
return (added, decided) if return_decision else added
|
||||||
|
|
||||||
|
|
||||||
|
_ORIGINAL_SPEC_ROUTE_TOOLS = spec_route_tools
|
||||||
|
|
||||||
|
|
||||||
|
async def spec_route_tools_async(
|
||||||
|
user_message: str,
|
||||||
|
schemas: Iterable[Dict[str, Any]],
|
||||||
|
disabled_tools: Optional[Set[str]] = None,
|
||||||
|
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||||
|
return_decision: bool = False,
|
||||||
|
):
|
||||||
|
"""Asynchronous non-blocking tool routing.
|
||||||
|
|
||||||
|
Preserves request-local ContextVar semantics, decision propagation,
|
||||||
|
cancellation behavior, timeout limits, and safe fallback on router failure.
|
||||||
|
"""
|
||||||
|
if spec_route_tools is not _ORIGINAL_SPEC_ROUTE_TOOLS:
|
||||||
|
return spec_route_tools(
|
||||||
|
user_message,
|
||||||
|
schemas,
|
||||||
|
disabled_tools=disabled_tools,
|
||||||
|
messages=messages,
|
||||||
|
return_decision=return_decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
added: Set[str] = set(prior_tool_names(messages))
|
||||||
|
decided = False
|
||||||
|
clear_decision()
|
||||||
|
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||||
|
if not url or not (user_message or "").strip():
|
||||||
|
return (added, False) if return_decision else added
|
||||||
|
t0 = time.time()
|
||||||
|
decision: Dict[str, Any] = {"ts": time.time(), "message": user_message[:160], "sticky": sorted(added)}
|
||||||
|
try:
|
||||||
|
disabled = set(disabled_tools or ())
|
||||||
|
lines: Dict[str, str] = {}
|
||||||
|
for s in schemas:
|
||||||
|
n = str(_fn(s).get("name") or "")
|
||||||
|
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||||
|
lines[n] = tool_line(s)
|
||||||
|
names = sorted(lines)
|
||||||
|
criteria = dict(_HEAD)
|
||||||
|
for i, n in enumerate(names, 1):
|
||||||
|
criteria[f"t{i}"] = lines[n]
|
||||||
|
req = {
|
||||||
|
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60"),
|
||||||
|
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": user_message},
|
||||||
|
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||||
|
}
|
||||||
|
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
while True:
|
||||||
|
remaining = max(0.2, deadline - time.time())
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
url + "/v1/systemone",
|
||||||
|
json=req,
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
timeout=remaining,
|
||||||
|
)
|
||||||
|
if resp.status_code == 529:
|
||||||
|
if time.time() + 0.15 >= deadline:
|
||||||
|
resp.raise_for_status()
|
||||||
|
decision["retries"] = decision.get("retries", 0) + 1
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
out = resp.json()["answers"]["tool"]
|
||||||
|
break
|
||||||
|
except httpx.HTTPStatusError as busy:
|
||||||
|
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
|
||||||
|
raise
|
||||||
|
decision["retries"] = decision.get("retries", 0) + 1
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
probs = out.get("probabilities") or {}
|
||||||
|
choice = out.get("choice")
|
||||||
|
if not isinstance(probs, dict) or choice not in criteria:
|
||||||
|
raise ValueError("invalid router choice")
|
||||||
|
for key, probability in probs.items():
|
||||||
|
if key not in criteria or isinstance(probability, bool):
|
||||||
|
raise ValueError("invalid router probability key")
|
||||||
|
number = float(probability)
|
||||||
|
if not math.isfinite(number) or not 0 <= number <= 1:
|
||||||
|
raise ValueError("invalid router probability")
|
||||||
|
probs = {key: float(value) for key, value in probs.items()}
|
||||||
|
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
||||||
|
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
||||||
|
ranked = sorted(
|
||||||
|
((p, k) for k, p in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True
|
||||||
|
)
|
||||||
|
picks: List[str] = []
|
||||||
|
if choice and choice.startswith("t") and choice[1:].isdigit() and float(probs.get(choice, 0.0)) >= min_conf:
|
||||||
|
picks.append(names[int(choice[1:]) - 1])
|
||||||
|
for p, k in ranked[:3]:
|
||||||
|
nm = names[int(k[1:]) - 1]
|
||||||
|
if nm not in picks and p >= second_min:
|
||||||
|
picks.append(nm)
|
||||||
|
_bmode = os.getenv("ODYSSEUS_SPEC_BROWSER_FALLBACK", "0")
|
||||||
|
if _bmode != "0" and "private_browser" in picks:
|
||||||
|
picks = [n for n in picks if n != "private_browser"]
|
||||||
|
extra = list(_MCP_BROWSER_TOOLS) if _bmode == "mcp" else []
|
||||||
|
for n in extra + ["web_search", "web_fetch"]:
|
||||||
|
if n not in picks:
|
||||||
|
picks.append(n)
|
||||||
|
added.update(picks)
|
||||||
|
decided = bool(picks) or (choice == "answer_directly" and float(probs.get("answer_directly", 0.0)) >= min_conf)
|
||||||
|
decision.update(choice=choice, tools=picks, ms=round((time.time() - t0) * 1000, 1),
|
||||||
|
top=[(names[int(k[1:]) - 1], round(p, 3)) for p, k in ranked[:3]],
|
||||||
|
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
||||||
|
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
||||||
|
except Exception as exc: # fail open
|
||||||
|
added = set(prior_tool_names(messages))
|
||||||
|
decided = False
|
||||||
|
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
||||||
|
logger.warning("spec router failed open: %s", exc)
|
||||||
|
decision["decided"] = decided
|
||||||
|
_LAST_DECISION.set(decision)
|
||||||
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||||
return (added, decided) if return_decision else added
|
return (added, decided) if return_decision else added
|
||||||
|
|
||||||
@@ -328,13 +473,59 @@ def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
|
|||||||
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
||||||
if kind5:
|
if kind5:
|
||||||
out["kind"] = ans.get("choice")
|
out["kind"] = ans.get("choice")
|
||||||
logger.info("spec think router: %s msg=%r", out, text[:120])
|
logger.info("spec think router: %s", out)
|
||||||
return out
|
return out
|
||||||
except Exception as exc: # fail open: the existing progressive-thinking rules still apply
|
except Exception as exc: # fail open: the existing progressive-thinking rules still apply
|
||||||
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_ORIGINAL_ROUTE_THINKING = route_thinking
|
||||||
|
|
||||||
|
|
||||||
|
async def route_thinking_async(user_message: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Asynchronous non-blocking route_thinking."""
|
||||||
|
if route_thinking is not _ORIGINAL_ROUTE_THINKING:
|
||||||
|
return route_thinking(user_message)
|
||||||
|
if not think_router_enabled():
|
||||||
|
return None
|
||||||
|
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||||
|
text = (user_message or "").strip()
|
||||||
|
if not url or not text:
|
||||||
|
return None
|
||||||
|
kind5 = os.getenv("ODYSSEUS_SPEC_THINK_QUESTION", "binary").strip().lower() == "kind5"
|
||||||
|
question = _KIND5_QUESTION if kind5 else {"type": "choice", "instructions": _THINK_INSTRUCTIONS,
|
||||||
|
"criteria": {"think": _THINK_DEF, "direct": _DIRECT_DEF}}
|
||||||
|
req = {
|
||||||
|
"model": os.getenv("ODYSSEUS_SPEC_THINK_MODEL", os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60")),
|
||||||
|
"state": {"agent": _AGENT, "conversation": [], "user_message": text[:4000]},
|
||||||
|
"questions": {"thinking": question},
|
||||||
|
}
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url + "/v1/systemone",
|
||||||
|
json=req,
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
ans = resp.json()["answers"]["thinking"]
|
||||||
|
probs = ans.get("probabilities") or {}
|
||||||
|
p = float(1.0 - probs.get("direct", 1.0)) if kind5 else float(probs.get("think", 0.0))
|
||||||
|
min_p = float(os.getenv("ODYSSEUS_SPEC_THINK_MIN_P", "0.5"))
|
||||||
|
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
||||||
|
if kind5:
|
||||||
|
out["kind"] = ans.get("choice")
|
||||||
|
logger.info("spec think router: %s", out)
|
||||||
|
return out
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ---- Ajax Spec driver: the router picks every step --------------------------------------------------------
|
# ---- Ajax Spec driver: the router picks every step --------------------------------------------------------
|
||||||
def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
||||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||||
@@ -389,7 +580,81 @@ def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[st
|
|||||||
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
||||||
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
||||||
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
||||||
logger.info("spec next action: %s msg=%r", action, (user_message or "")[:80])
|
logger.info("spec next action: %s", action)
|
||||||
|
return action
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("spec next action failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_ORIGINAL_NEXT_ACTION = next_action
|
||||||
|
|
||||||
|
|
||||||
|
async def next_action_async(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
||||||
|
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||||
|
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Asynchronous non-blocking next_action."""
|
||||||
|
if next_action is not _ORIGINAL_NEXT_ACTION:
|
||||||
|
return next_action(user_message, latest_result, schemas, messages=messages, disabled_tools=disabled_tools)
|
||||||
|
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
t0 = time.time()
|
||||||
|
disabled = set(disabled_tools or ())
|
||||||
|
lines: Dict[str, str] = {}
|
||||||
|
for s in schemas:
|
||||||
|
n = str(_fn(s).get("name") or "")
|
||||||
|
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||||
|
lines[n] = tool_line(s)
|
||||||
|
names = sorted(lines)
|
||||||
|
criteria = dict(_HEAD)
|
||||||
|
for i, n in enumerate(names, 1):
|
||||||
|
criteria[f"t{i}"] = lines[n]
|
||||||
|
um = (user_message or "")[-3000:]
|
||||||
|
if latest_result:
|
||||||
|
um += "\n\nLatest tool result:\n" + latest_result[-1500:]
|
||||||
|
req = {
|
||||||
|
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-agentic-v4"),
|
||||||
|
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": um},
|
||||||
|
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
while True:
|
||||||
|
remaining = max(0.2, deadline - time.time())
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
url + "/v1/systemone",
|
||||||
|
json=req,
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
timeout=remaining,
|
||||||
|
)
|
||||||
|
if resp.status_code == 529:
|
||||||
|
if time.time() + 0.15 >= deadline:
|
||||||
|
resp.raise_for_status()
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
out = resp.json()["answers"]["tool"]
|
||||||
|
break
|
||||||
|
except httpx.HTTPStatusError as busy:
|
||||||
|
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
probs = out.get("probabilities") or {}
|
||||||
|
choice = str(out.get("choice") or "")
|
||||||
|
if choice.startswith("t") and choice[1:].isdigit():
|
||||||
|
action = {"kind": "tool", "name": names[int(choice[1:]) - 1]}
|
||||||
|
elif choice in ("answer_directly", "ask_user"):
|
||||||
|
action = {"kind": choice, "name": choice}
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
||||||
|
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
||||||
|
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
||||||
|
logger.info("spec next action: %s", action)
|
||||||
return action
|
return action
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("spec next action failed: %s", exc)
|
logger.warning("spec next action failed: %s", exc)
|
||||||
|
|||||||
+10
-12
@@ -47,25 +47,23 @@ def resolve_task_candidates(
|
|||||||
candidates.append((url, model, headers or {}))
|
candidates.append((url, model, headers or {}))
|
||||||
|
|
||||||
if override_url and override_model:
|
if override_url and override_model:
|
||||||
headers = override_headers or {}
|
|
||||||
try:
|
try:
|
||||||
from src.database import ModelEndpoint, SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.endpoint_resolver import normalize_base, resolve_endpoint_runtime, build_headers
|
from src.endpoint_resolver import (
|
||||||
|
build_chat_url, build_headers, resolve_endpoint_runtime,
|
||||||
|
resolve_owner_registered_endpoint,
|
||||||
|
)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
from src.auth_helpers import owner_filter
|
ep = resolve_owner_registered_endpoint(db, override_url, owner)
|
||||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
for ep in owner_filter(query, ModelEndpoint, owner).all():
|
_append(build_chat_url(runtime_base), override_model, build_headers(api_key, runtime_base))
|
||||||
base = normalize_base(getattr(ep, "base_url", "") or "")
|
|
||||||
if _same_endpoint_base(override_url, base):
|
|
||||||
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
|
||||||
headers = build_headers(api_key, runtime_base or base)
|
|
||||||
break
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# A missing, foreign, disabled or unreadable registration is not
|
||||||
|
# authority to contact a caller-provided endpoint or use its key.
|
||||||
pass
|
pass
|
||||||
_append(override_url, override_model, headers)
|
|
||||||
_append(*resolve_task_endpoint(fallback_url, fallback_model, fallback_headers, owner=owner))
|
_append(*resolve_task_endpoint(fallback_url, fallback_model, fallback_headers, owner=owner))
|
||||||
_append(*resolve_endpoint("utility", owner=owner))
|
_append(*resolve_endpoint("utility", owner=owner))
|
||||||
_append(*resolve_endpoint("default", owner=owner))
|
_append(*resolve_endpoint("default", owner=owner))
|
||||||
|
|||||||
+72
-27
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||||
|
|
||||||
from core.auth import RESERVED_USERNAMES
|
from core.auth import RESERVED_USERNAMES
|
||||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS, auth_disabled
|
||||||
from src.task_action_policy import (
|
from src.task_action_policy import (
|
||||||
is_admin_only_task_action,
|
is_admin_only_task_action,
|
||||||
owner_has_admin_task_privileges,
|
owner_has_admin_task_privileges,
|
||||||
@@ -21,6 +21,15 @@ from src.task_action_policy import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskEndpointAuthorityError(RuntimeError):
|
||||||
|
"""A scheduled task's endpoint or its credentials cannot be authorized."""
|
||||||
|
|
||||||
|
|
||||||
|
def _require_task_owner(owner):
|
||||||
|
if not str(owner or "").strip() and not auth_disabled():
|
||||||
|
raise TaskEndpointAuthorityError("Scheduled task has no owner in authenticated mode")
|
||||||
|
|
||||||
|
|
||||||
def _is_sft_fixture_owner(owner: str | None) -> bool:
|
def _is_sft_fixture_owner(owner: str | None) -> bool:
|
||||||
"""Synthetic SFT accounts may manage tasks but must never auto-fire them."""
|
"""Synthetic SFT accounts may manage tasks but must never auto-fire them."""
|
||||||
return str(owner or "").strip().lower().startswith("sft_")
|
return str(owner or "").strip().lower().startswith("sft_")
|
||||||
@@ -123,6 +132,23 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
|
|||||||
_shared_cache_pending.pop(key, None)
|
_shared_cache_pending.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_cron(cron_expression: str) -> bool:
|
||||||
|
"""True for a standard 5-field cron expression that croniter can parse.
|
||||||
|
|
||||||
|
croniter also accepts 6- and 7-field expressions with a seconds field, but
|
||||||
|
the scheduler works at one-minute resolution and would run such a task
|
||||||
|
every second.
|
||||||
|
"""
|
||||||
|
if len((cron_expression or "").split()) != 5:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from croniter import croniter
|
||||||
|
croniter(cron_expression)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def compute_next_run(schedule: str, scheduled_time: str,
|
def compute_next_run(schedule: str, scheduled_time: str,
|
||||||
scheduled_day: int = None,
|
scheduled_day: int = None,
|
||||||
scheduled_date: datetime = None,
|
scheduled_date: datetime = None,
|
||||||
@@ -166,6 +192,9 @@ def compute_next_run(schedule: str, scheduled_time: str,
|
|||||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
if schedule == "cron" and cron_expression:
|
if schedule == "cron" and cron_expression:
|
||||||
|
if not is_valid_cron(cron_expression):
|
||||||
|
logger.warning(f"Invalid cron expression '{cron_expression}'")
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
cron = croniter(cron_expression, now)
|
cron = croniter(cron_expression, now)
|
||||||
@@ -297,7 +326,7 @@ def _digest_windows(now):
|
|||||||
|
|
||||||
|
|
||||||
def _checkin_calendar_events(db, owner, start, end):
|
def _checkin_calendar_events(db, owner, start, end):
|
||||||
"""Calendar events in [start, end] for ONE owner, for the check-in digest.
|
"""Calendar events in [start, end) for ONE owner, for the check-in digest.
|
||||||
|
|
||||||
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
|
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
|
||||||
The digest query had no owner scope, so it pulled EVERY user's events into
|
The digest query had no owner scope, so it pulled EVERY user's events into
|
||||||
@@ -311,7 +340,7 @@ def _checkin_calendar_events(db, owner, start, end):
|
|||||||
.filter(
|
.filter(
|
||||||
_CC.owner == owner,
|
_CC.owner == owner,
|
||||||
_CE.dtstart >= start,
|
_CE.dtstart >= start,
|
||||||
_CE.dtstart <= end,
|
_CE.dtstart < end,
|
||||||
_CE.status != "cancelled",
|
_CE.status != "cancelled",
|
||||||
)
|
)
|
||||||
.order_by(_CE.dtstart)
|
.order_by(_CE.dtstart)
|
||||||
@@ -1345,6 +1374,7 @@ class TaskScheduler:
|
|||||||
|
|
||||||
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
|
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
|
||||||
"""Execute a built-in action (no LLM needed)."""
|
"""Execute a built-in action (no LLM needed)."""
|
||||||
|
_require_task_owner(task.owner)
|
||||||
from src.builtin_actions import BUILTIN_ACTIONS
|
from src.builtin_actions import BUILTIN_ACTIONS
|
||||||
from src.agent_runtime.authority import (
|
from src.agent_runtime.authority import (
|
||||||
bind_request_authority, restore_task_authority, task_operation,
|
bind_request_authority, restore_task_authority, task_operation,
|
||||||
@@ -1634,6 +1664,7 @@ class TaskScheduler:
|
|||||||
async def _execute_llm_task(self, task, db) -> str:
|
async def _execute_llm_task(self, task, db) -> str:
|
||||||
"""Execute an LLM task with full tool access via the agent loop."""
|
"""Execute an LLM task with full tool access via the agent loop."""
|
||||||
from core.database import Session as DbSession, ChatMessage, CrewMember
|
from core.database import Session as DbSession, ChatMessage, CrewMember
|
||||||
|
_require_task_owner(task.owner)
|
||||||
|
|
||||||
# If this task is wired to a CrewMember (personal assistant, custom
|
# If this task is wired to a CrewMember (personal assistant, custom
|
||||||
# crew), prefer the crew member's persona/model/endpoint as overrides.
|
# crew), prefer the crew member's persona/model/endpoint as overrides.
|
||||||
@@ -1654,6 +1685,11 @@ class TaskScheduler:
|
|||||||
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
raise RuntimeError("No model/endpoint configured")
|
raise RuntimeError("No model/endpoint configured")
|
||||||
|
# Authorize the original URL before normalization can erase a query,
|
||||||
|
# fragment, or caller-selected path. Stored tasks do not themselves
|
||||||
|
# grant permission to contact an endpoint.
|
||||||
|
from src.endpoint_resolver import resolve_owner_registered_endpoint
|
||||||
|
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
||||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||||
# Record the resolved model so _execute_task_locked can persist it on
|
# Record the resolved model so _execute_task_locked can persist it on
|
||||||
# the run (tasks rarely pin a model, so this is the only record of
|
# the run (tasks rarely pin a model, so this is the only record of
|
||||||
@@ -1772,6 +1808,8 @@ class TaskScheduler:
|
|||||||
relevant_tools=relevant_tools,
|
relevant_tools=relevant_tools,
|
||||||
datetime_context_msg=_dt_msg,
|
datetime_context_msg=_dt_msg,
|
||||||
)
|
)
|
||||||
|
except TaskEndpointAuthorityError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Agent loop failed for task '{task.name}', falling back to simple call: {e}")
|
logger.warning(f"Agent loop failed for task '{task.name}', falling back to simple call: {e}")
|
||||||
from src.task_endpoint import task_llm_call_async
|
from src.task_endpoint import task_llm_call_async
|
||||||
@@ -1986,6 +2024,7 @@ class TaskScheduler:
|
|||||||
override_user_message: str | None = None,
|
override_user_message: str | None = None,
|
||||||
datetime_context_msg: dict | None = None) -> str:
|
datetime_context_msg: dict | None = None) -> str:
|
||||||
"""Run the full agent loop with tool access, collecting the final text."""
|
"""Run the full agent loop with tool access, collecting the final text."""
|
||||||
|
_require_task_owner(task.owner)
|
||||||
from src.agent_loop import stream_agent_loop
|
from src.agent_loop import stream_agent_loop
|
||||||
from src.agent_runtime.authority import restore_task_authority
|
from src.agent_runtime.authority import restore_task_authority
|
||||||
|
|
||||||
@@ -2015,8 +2054,8 @@ class TaskScheduler:
|
|||||||
headers = build_headers(api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
finally:
|
finally:
|
||||||
db2.close()
|
db2.close()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
raise TaskEndpointAuthorityError("Scheduled task endpoint is unavailable or unauthorized") from exc
|
||||||
full_text = ""
|
full_text = ""
|
||||||
final_text_replaced = False
|
final_text_replaced = False
|
||||||
tool_results = []
|
tool_results = []
|
||||||
@@ -2172,11 +2211,16 @@ class TaskScheduler:
|
|||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
|
|
||||||
# Resolve endpoint/model: research settings > task settings > session defaults
|
# Resolve endpoint/model: research settings > task settings > session defaults
|
||||||
|
_require_task_owner(task.owner)
|
||||||
endpoint_url = task.endpoint_url
|
endpoint_url = task.endpoint_url
|
||||||
model = task.model
|
model = task.model
|
||||||
headers = {}
|
headers = {}
|
||||||
headers_from_resolver = False
|
headers_from_resolver = False
|
||||||
|
|
||||||
|
if endpoint_url:
|
||||||
|
from src.endpoint_resolver import resolve_owner_registered_endpoint
|
||||||
|
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
||||||
|
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
@@ -2213,8 +2257,8 @@ class TaskScheduler:
|
|||||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
||||||
endpoint_url = build_chat_url(base)
|
endpoint_url = build_chat_url(base)
|
||||||
headers = build_headers(api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
raise TaskEndpointAuthorityError("Scheduled research endpoint is unavailable or unauthorized") from exc
|
||||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||||
|
|
||||||
max_tokens, extraction_timeout, extraction_concurrency = (
|
max_tokens, extraction_timeout, extraction_concurrency = (
|
||||||
@@ -2348,11 +2392,11 @@ class TaskScheduler:
|
|||||||
have to special-case each tool's schema; the MCP tool ignores keys it
|
have to special-case each tool's schema; the MCP tool ignores keys it
|
||||||
doesn't recognise.
|
doesn't recognise.
|
||||||
"""
|
"""
|
||||||
|
_require_task_owner(task.owner)
|
||||||
from src.tool_utils import get_mcp_manager
|
from src.tool_utils import get_mcp_manager
|
||||||
mcp = get_mcp_manager()
|
mcp = get_mcp_manager()
|
||||||
if not mcp:
|
if not mcp:
|
||||||
logger.warning(f"Task {task.id}: MCP manager not available for delivery")
|
raise RuntimeError("MCP manager not available for task delivery")
|
||||||
return
|
|
||||||
|
|
||||||
# Resolve recipient — prefer the configured email From (the established
|
# Resolve recipient — prefer the configured email From (the established
|
||||||
# "email yourself" pattern from daily_brief), fall back to task.owner.
|
# "email yourself" pattern from daily_brief), fall back to task.owner.
|
||||||
@@ -2361,7 +2405,7 @@ class TaskScheduler:
|
|||||||
recipient = None
|
recipient = None
|
||||||
try:
|
try:
|
||||||
from routes.email_helpers import _get_email_config
|
from routes.email_helpers import _get_email_config
|
||||||
cfg = _get_email_config() or {}
|
cfg = _get_email_config(owner=task.owner or "") or {}
|
||||||
recipient = cfg.get("from_address") or None
|
recipient = cfg.get("from_address") or None
|
||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
logger.debug(f"_deliver_via_mcp: email config lookup failed: {_e}")
|
logger.debug(f"_deliver_via_mcp: email config lookup failed: {_e}")
|
||||||
@@ -2377,6 +2421,14 @@ class TaskScheduler:
|
|||||||
"X-Odysseus-Ref": str(task.id),
|
"X-Odysseus-Ref": str(task.id),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if tool_name.startswith("mcp__email__"):
|
||||||
|
# The email server uses this reserved field for its account and
|
||||||
|
# approval scope; it must come from the task owner, never a model.
|
||||||
|
from src.tool_execution import _EMAIL_MCP_OWNER_ARG, _EMAIL_MCP_SESSION_ARG
|
||||||
|
if task.owner:
|
||||||
|
args[_EMAIL_MCP_OWNER_ARG] = task.owner
|
||||||
|
if getattr(task, "session_id", None):
|
||||||
|
args[_EMAIL_MCP_SESSION_ARG] = task.session_id
|
||||||
if recipient:
|
if recipient:
|
||||||
# Cover the common field names so we work across MCP servers (Gmail,
|
# Cover the common field names so we work across MCP servers (Gmail,
|
||||||
# generic SMTP, Slack DMs, etc.) without having to hard-code each.
|
# generic SMTP, Slack DMs, etc.) without having to hard-code each.
|
||||||
@@ -2391,25 +2443,18 @@ class TaskScheduler:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
mcp_result = await mcp.call_tool(tool_name, args)
|
mcp_result = await mcp.call_tool(tool_name, args)
|
||||||
stderr = mcp_result.get("stderr", "")
|
if isinstance(mcp_result, dict):
|
||||||
stdout = mcp_result.get("stdout", "")
|
from src.tool_execution import _normalize_mcp_text_error
|
||||||
body_len = len(result or "")
|
mcp_result = _normalize_mcp_text_error(dict(mcp_result))
|
||||||
exit_code = mcp_result.get("exit_code", 0)
|
if not isinstance(mcp_result, dict) or mcp_result.get("error") or mcp_result.get("exit_code") != 0:
|
||||||
if exit_code != 0:
|
raise RuntimeError("MCP task delivery failed or returned an invalid result")
|
||||||
logger.warning(
|
if tool_name == "mcp__email__send_email" and not str(mcp_result.get("stdout") or "").strip().startswith("Sent email to "):
|
||||||
f"Task {task.id} MCP delivery FAILED via {tool_name}: "
|
raise RuntimeError("MCP email delivery did not report a completed send")
|
||||||
f"exit={exit_code} stderr={stderr[:400]!r} stdout={stdout[:400]!r}"
|
logger.info("Task %s delivered via MCP tool %s (recipient_set=%s, body=%sb)",
|
||||||
)
|
task.id, tool_name, bool(recipient), len(result or ""))
|
||||||
else:
|
|
||||||
# Include the MCP tool's own stdout (e.g. email_server returns
|
|
||||||
# "Sent email to ... with subject ...") + the body size so a
|
|
||||||
# silent SMTP failure is easier to spot in the logs.
|
|
||||||
logger.info(
|
|
||||||
f"Task {task.id} delivered via MCP tool {tool_name} "
|
|
||||||
f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def run_task_now(self, task_id: str, *, force: bool = False):
|
async def run_task_now(self, task_id: str, *, force: bool = False):
|
||||||
"""Manually trigger a task execution."""
|
"""Manually trigger a task execution."""
|
||||||
|
|||||||
@@ -227,8 +227,16 @@ _register(
|
|||||||
ToolEffect.READ_PRIVATE,
|
ToolEffect.READ_PRIVATE,
|
||||||
)
|
)
|
||||||
_register(
|
_register(
|
||||||
{"plan_edit", "run_editor_command"},
|
{"plan_edit"},
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"run_editor_command"},
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
ToolEffect.UI_SIDE_EFFECT,
|
ToolEffect.UI_SIDE_EFFECT,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
)
|
)
|
||||||
_register(
|
_register(
|
||||||
{
|
{
|
||||||
@@ -402,7 +410,7 @@ _PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
|
|||||||
"manage_endpoints": frozenset({"add", "delete", "enable", "disable"}),
|
"manage_endpoints": frozenset({"add", "delete", "enable", "disable"}),
|
||||||
"manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}),
|
"manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}),
|
||||||
"manage_settings": frozenset(
|
"manage_settings": frozenset(
|
||||||
{"set", "delete", "reset", "disable_tool", "enable_tool"}
|
{"set", "delete", "reset", "disable_tool"}
|
||||||
),
|
),
|
||||||
"manage_tokens": frozenset({"create", "delete"}),
|
"manage_tokens": frozenset({"create", "delete"}),
|
||||||
"manage_webhooks": frozenset({"add", "delete", "enable", "disable"}),
|
"manage_webhooks": frozenset({"add", "delete", "enable", "disable"}),
|
||||||
|
|||||||
@@ -1559,7 +1559,7 @@ def _agent_subprocess_env() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _legacy_private_browser_enabled() -> bool:
|
def _legacy_private_browser_enabled() -> bool:
|
||||||
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}
|
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
async def _legacy_private_browser(operation, transport, *, progress_cb=None, session_id=None, owner=None):
|
async def _legacy_private_browser(operation, transport, *, progress_cb=None, session_id=None, owner=None):
|
||||||
@@ -1757,11 +1757,13 @@ async def execute_tool_block(
|
|||||||
|
|
||||||
transport = operation.transport_tool
|
transport = operation.transport_tool
|
||||||
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
|
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
|
||||||
# Restored pre-Wave-3 private_browser (agent-browser CLI). The Wave 3
|
# The legacy process cannot establish the sealed browser/page identity
|
||||||
# producer in src.browser_identity refuses every page action in this
|
# required by the current runtime. Never execute it ahead of resource
|
||||||
# build. This compatibility transport requires an explicit opt-in.
|
# binding and the untrusted-context approval gate, even when opted in.
|
||||||
return await _legacy_private_browser(
|
return f"{transport}: UNSUPPORTED", {
|
||||||
operation, transport, progress_cb=progress_cb, session_id=session_id, owner=owner)
|
**page_unavailable(),
|
||||||
|
"error": "Legacy browser transport cannot establish the required browser authority.",
|
||||||
|
}
|
||||||
if operation.tool == "private_browser":
|
if operation.tool == "private_browser":
|
||||||
try:
|
try:
|
||||||
_, browser_args = parse_browser_operation(operation.input)
|
_, browser_args = parse_browser_operation(operation.input)
|
||||||
|
|||||||
+1
-1
@@ -145,7 +145,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
|||||||
"manage_tokens": "API token management: list, create, or delete API access tokens.",
|
"manage_tokens": "API token management: list, create, or delete API access tokens.",
|
||||||
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
|
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
|
||||||
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
|
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
|
||||||
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also toggles tools on/off (disable_tool/enable_tool/list_tools). Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
|
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also turns tools off globally (disable_tool) and lists disabled ones (list_tools); re-enabling is only done by the user in Settings > Agent Tools. Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
|
||||||
"create_session": "Create a new chat with a name and model.",
|
"create_session": "Create a new chat with a name and model.",
|
||||||
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
|
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
|
||||||
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
||||||
|
|||||||
+8
-7
@@ -41,6 +41,7 @@ _REQUIRED_NATIVE_TOOL_ARGS = {
|
|||||||
"write_file": ("path",),
|
"write_file": ("path",),
|
||||||
"edit_file": ("path",),
|
"edit_file": ("path",),
|
||||||
"apply_patch": ("patch_text", "patchText", "patch"),
|
"apply_patch": ("patch_text", "patchText", "patch"),
|
||||||
|
"generate_image": ("prompt",),
|
||||||
"host_shell": ("command",),
|
"host_shell": ("command",),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1292,14 +1293,14 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "manage_settings",
|
"name": "manage_settings",
|
||||||
"description": "Manage user preferences and settings. Use `disable_tool`/`enable_tool`/`list_tools` to turn individual tools on or off globally (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Use list/get/set/delete for free-form preferences.",
|
"description": "Manage user preferences and settings. Use `disable_tool`/`list_tools` to turn individual tools off globally or list what is disabled (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Disabled tools can only be re-enabled by the user in Settings > Agent Tools, never from chat. Use list/get/set/delete for free-form preferences.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "enable_tool", "list_tools"]},
|
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "list_tools"]},
|
||||||
"key": {"type": "string", "description": "Setting key (for get/set/delete)"},
|
"key": {"type": "string", "description": "Setting key (for get/set/delete)"},
|
||||||
"value": {"description": "Setting value (for set) — can be string, number, boolean, or object"},
|
"value": {"description": "Setting value (for set) — can be string, number, boolean, or object"},
|
||||||
"tool": {"type": "string", "description": "Tool name to disable/enable (for disable_tool/enable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
|
"tool": {"type": "string", "description": "Tool name to disable (for disable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
}
|
}
|
||||||
@@ -1545,7 +1546,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"properties": {
|
"properties": {
|
||||||
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
|
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
|
||||||
"params": {"type": "object", "description": "Parameters for the op"},
|
"params": {"type": "object", "description": "Parameters for the op"},
|
||||||
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"},
|
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"},
|
||||||
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
|
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
|
||||||
},
|
},
|
||||||
"required": ["op"]
|
"required": ["op"]
|
||||||
@@ -1560,7 +1561,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"}
|
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1583,7 +1584,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."},
|
"steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."},
|
||||||
"place": {"type": "boolean", "description": "Place the result as a new layer (default true)"},
|
"place": {"type": "boolean", "description": "Place the result as a new layer (default true)"},
|
||||||
"model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"},
|
"model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"},
|
||||||
"session": {"type": "string", "description": "Editor session id"}
|
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"}
|
||||||
},
|
},
|
||||||
"required": ["op"]
|
"required": ["op"]
|
||||||
}
|
}
|
||||||
@@ -2000,7 +2001,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}:
|
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}:
|
||||||
# Restored pre-Wave-3 private_browser (see src.tool_execution._legacy_private_browser).
|
# Restored pre-Wave-3 private_browser (see src.tool_execution._legacy_private_browser).
|
||||||
from src.private_browser_legacy_schema import PRIVATE_BROWSER_SCHEMA as _PB_LEGACY
|
from src.private_browser_legacy_schema import PRIVATE_BROWSER_SCHEMA as _PB_LEGACY
|
||||||
|
|
||||||
|
|||||||
@@ -769,6 +769,8 @@ _APP_API_BLOCKLIST_PREFIXES = (
|
|||||||
# {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
|
# {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
|
||||||
# Use dedicated tools or UI flows instead.
|
# Use dedicated tools or UI flows instead.
|
||||||
_APP_API_BLOCKLIST_METHOD_PATH = (
|
_APP_API_BLOCKLIST_METHOD_PATH = (
|
||||||
|
("POST", "/api/tools"), # global tool policy is changed by a human in Settings
|
||||||
|
("PATCH", "/api/mcp/servers"), # per-server MCP tool policy and server toggles, same reason
|
||||||
("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool
|
("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool
|
||||||
("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead
|
("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead
|
||||||
("DELETE", "/api/cookbook/state"),
|
("DELETE", "/api/cookbook/state"),
|
||||||
@@ -893,6 +895,10 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
||||||
return {"error": f"Unsupported method: {method}", "exit_code": 1}
|
return {"error": f"Unsupported method: {method}", "exit_code": 1}
|
||||||
if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
|
if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
|
||||||
|
if path.startswith("/api/tools"):
|
||||||
|
return {"error": "Tool policy cannot be replaced from chat. Re-enable tools in Settings > Agent Tools; use manage_settings to list or disable them.", "exit_code": 1}
|
||||||
|
if path.startswith("/api/mcp/servers"):
|
||||||
|
return {"error": "MCP server and tool policy cannot be changed from chat. Ask the user to change it in Settings > Integrations.", "exit_code": 1}
|
||||||
if "/api/email/accounts" in path:
|
if "/api/email/accounts" in path:
|
||||||
return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1}
|
return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1}
|
||||||
if "/api/cookbook/packages/install" in path:
|
if "/api/cookbook/packages/install" in path:
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
width: 240px;
|
width: 240px;
|
||||||
background: var(--sidebar-bg, var(--panel));
|
background: var(--sidebar-bg, var(--panel));
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
|
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease, visibility 0.25s;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -361,12 +361,23 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
/* !important so it beats the inline width init.js restores from storage —
|
/* !important so it beats the inline width init.js restores from storage —
|
||||||
otherwise the width never changes and only opacity animates, making the
|
otherwise the width never changes and only opacity animates, making the
|
||||||
collapse look instant. With this, width animates from the inline value
|
collapse look instant. With this, width animates from the inline value
|
||||||
down to 0 via the .sidebar width transition. */
|
down to 0 via the .sidebar width transition.
|
||||||
|
display overrides the global .hidden utility, which would otherwise
|
||||||
|
remove the box before any transition can run. visibility (transitioned
|
||||||
|
on .sidebar) flips to hidden only once the collapse has finished,
|
||||||
|
taking the closed sidebar out of the tab order. */
|
||||||
|
display: flex;
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
border: none;
|
border: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* IDs so this also beats the mobile drawer's !important transition. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#sidebar, #icon-rail { transition: none !important; }
|
||||||
}
|
}
|
||||||
/* ===== Sidebar User Bar ===== */
|
/* ===== Sidebar User Bar ===== */
|
||||||
.sidebar-user-bar {
|
.sidebar-user-bar {
|
||||||
@@ -449,10 +460,6 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.sidebar.hidden .sidebar-user-bar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sticky sidebar header — logo, never scrolls */
|
/* Sticky sidebar header — logo, never scrolls */
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -673,6 +680,24 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
margin-bottom: calc(-1 * var(--safe-bottom));
|
margin-bottom: calc(-1 * var(--safe-bottom));
|
||||||
padding-bottom: calc(8px + var(--safe-bottom));
|
padding-bottom: calc(8px + var(--safe-bottom));
|
||||||
}
|
}
|
||||||
|
/* Tuck the rail under the sidebar (which paints above it) and slide it
|
||||||
|
out as the sidebar collapses, so the combined width eases instead of
|
||||||
|
jumping by the rail's 48px when the two swap. */
|
||||||
|
.icon-rail,
|
||||||
|
.icon-rail.right-side {
|
||||||
|
margin-inline-end: -48px;
|
||||||
|
transition: margin-inline-end 0.25s ease, display 0.25s allow-discrete;
|
||||||
|
}
|
||||||
|
body.sidebar-collapsed:not(.hamburger-only) .icon-rail,
|
||||||
|
html.ody-sidebar-mini .icon-rail {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
@starting-style {
|
||||||
|
body.sidebar-collapsed:not(.hamburger-only) .icon-rail,
|
||||||
|
html.ody-sidebar-mini .icon-rail {
|
||||||
|
margin-inline-end: -48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.icon-rail-divider {
|
.icon-rail-divider {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
@@ -4567,7 +4592,9 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
width:100%; background:var(--bg); color:var(--fg);
|
width:100%; background:var(--bg); color:var(--fg);
|
||||||
border:1px solid var(--border); border-radius:6px;
|
border:1px solid var(--border); border-radius:6px;
|
||||||
padding:8px; font-family:inherit; font-size:0.95rem;
|
padding:8px; font-family:inherit; font-size:0.95rem;
|
||||||
resize:vertical; outline:none;
|
resize:both; outline:none;
|
||||||
|
field-sizing:content;
|
||||||
|
max-width:100%; box-sizing:border-box;
|
||||||
min-height:80px;
|
min-height:80px;
|
||||||
}
|
}
|
||||||
.edit-textarea:focus { border-color:var(--red); }
|
.edit-textarea:focus { border-color:var(--red); }
|
||||||
@@ -5116,7 +5143,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
width: 80% !important;
|
width: 80% !important;
|
||||||
max-width: 340px;
|
max-width: 340px;
|
||||||
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
|
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
|
||||||
transition: transform 0.25s ease, opacity 0.25s ease !important;
|
transition: transform 0.25s ease, opacity 0.25s ease, visibility 0.25s !important;
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
/* Force opaque background — Firefox mobile renders the
|
/* Force opaque background — Firefox mobile renders the
|
||||||
@@ -5147,6 +5174,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
}
|
}
|
||||||
html.ody-sidebar-off .sidebar,
|
html.ody-sidebar-off .sidebar,
|
||||||
html.ody-mobile-startup-sidebar-hidden .sidebar {
|
html.ody-mobile-startup-sidebar-hidden .sidebar {
|
||||||
|
width: 80% !important;
|
||||||
transform: translateX(-100%) !important;
|
transform: translateX(-100%) !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
|
|||||||
@@ -90,12 +90,6 @@
|
|||||||
.lib-tabs,
|
.lib-tabs,
|
||||||
.gallery-tabs,
|
.gallery-tabs,
|
||||||
.preset-tabs {
|
.preset-tabs {
|
||||||
/* A flex-item tab strip defaults to min-width:auto, so it grows to its
|
|
||||||
content width inside a narrow modal and the end tabs get clipped by the
|
|
||||||
modal's overflow instead of scrolling. min-width:0 lets the strip shrink
|
|
||||||
to the container so the overflow-x:auto below actually engages. */
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: nowrap !important;
|
||||||
overflow-x: auto !important;
|
overflow-x: auto !important;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
|||||||
+106
-17
@@ -111,7 +111,9 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
row-gap: 4px;
|
row-gap: 4px;
|
||||||
min-height: 40px;
|
height: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 44px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -206,9 +208,19 @@
|
|||||||
pushed to the right edge. */
|
pushed to the right edge. */
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
/* When transform section is active in controls, suppress any underlying tool section
|
||||||
|
so transform controls (Cancel, Apply, X, Y, W, H) have the full width and are not
|
||||||
|
overlapped or crowded by tool options (e.g. marquee invert/erase buttons). */
|
||||||
|
.ge-controls.ge-transform-active > div:not(#ge-transform-section),
|
||||||
|
.ge-controls:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section),
|
||||||
|
.ge-controls--in-options:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
/* The Text tool's option set is too wide for the thin strip: let the bar grow
|
/* The Text tool's option set is too wide for the thin strip: let the bar grow
|
||||||
and wrap onto extra rows instead of clipping the trailing fields. */
|
and wrap onto extra rows instead of clipping the trailing fields. */
|
||||||
.ge-options-bar.ge-options-tall {
|
.ge-options-bar.ge-options-tall {
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
@@ -254,7 +266,7 @@
|
|||||||
right after the caret instead of leaving a wide gap. */
|
right after the caret instead of leaving a wide gap. */
|
||||||
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }
|
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }
|
||||||
.ge-controls--in-options .ge-control-row {
|
.ge-controls--in-options .ge-control-row {
|
||||||
margin: 0;
|
margin: 0 !important;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -488,8 +500,12 @@
|
|||||||
}
|
}
|
||||||
.ge-opt-empty { font-size: 11px; opacity: 0.5; }
|
.ge-opt-empty { font-size: 11px; opacity: 0.5; }
|
||||||
.ge-options-empty .ge-options-sep { display: none; }
|
.ge-options-empty .ge-options-sep { display: none; }
|
||||||
|
/* An editor opened at phone width keeps its tool controls in their own bottom
|
||||||
|
sheet, so the strip has nothing to show. One opened wider than 700px moves
|
||||||
|
the controls into this strip; hiding it then would strand every tool control
|
||||||
|
(tablet widths of 701-820px, or a window narrowed after opening). */
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
.ge-options-bar { display: none; }
|
.ge-options-bar:not(:has(.ge-controls--in-options)) { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Document tab strip (Photoshop-style) — names the open document and
|
/* Document tab strip (Photoshop-style) — names the open document and
|
||||||
@@ -4198,7 +4214,11 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
.ge-save-state-icon { display: none; }
|
.ge-save-state-icon { display: inline-block; vertical-align: middle; }
|
||||||
|
.ge-save-state-icon[hidden] { display: none !important; }
|
||||||
|
.ge-save-state-saved { color: var(--fg); }
|
||||||
|
.ge-save-state-dirty { color: var(--accent, var(--red)); }
|
||||||
|
.ge-save-state-error { color: var(--danger, #ef4444); }
|
||||||
.ge-save-label { white-space: nowrap; min-width: 0; }
|
.ge-save-label { white-space: nowrap; min-width: 0; }
|
||||||
/* Save button: glyph on its own row, then the SAVE action label and the
|
/* Save button: glyph on its own row, then the SAVE action label and the
|
||||||
live draft-status chip share the second row. */
|
live draft-status chip share the second row. */
|
||||||
@@ -6341,6 +6361,10 @@ button .spinner-whirlpool {
|
|||||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.72);
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.72);
|
||||||
display: none;
|
display: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
contain: strict;
|
||||||
|
will-change: transform;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
}
|
}
|
||||||
/* Pixel brush: a hard square footprint instead of a feathered circle. */
|
/* Pixel brush: a hard square footprint instead of a feathered circle. */
|
||||||
.ge-brush-cursor.ge-brush-cursor--pixel {
|
.ge-brush-cursor.ge-brush-cursor--pixel {
|
||||||
@@ -6622,6 +6646,7 @@ button .spinner-whirlpool {
|
|||||||
}
|
}
|
||||||
.ge-right-panel.expanded {
|
.ge-right-panel.expanded {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
/* Minimized: panel slides almost fully off-screen — only a thin
|
/* Minimized: panel slides almost fully off-screen — only a thin
|
||||||
handle strip remains visible so the user can swipe back up. */
|
handle strip remains visible so the user can swipe back up. */
|
||||||
@@ -8134,7 +8159,12 @@ input.ge-ctl-check:focus-visible {
|
|||||||
}
|
}
|
||||||
.ge-setup-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
.ge-setup-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
|
/* Cover the phone viewport rather than the small, scrollable canvas area;
|
||||||
|
the layers sheet otherwise hides the checklist's lower controls. */
|
||||||
|
.ge-preflight { position: fixed; }
|
||||||
.ge-preflight-card { padding: 13px 14px; }
|
.ge-preflight-card { padding: 13px 14px; }
|
||||||
|
.ge-preflight-foot { flex-wrap: wrap; }
|
||||||
|
.ge-preflight-start { white-space: nowrap; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Quick Edit — premium look ────────────────────────────────────────── */
|
/* ── Quick Edit — premium look ────────────────────────────────────────── */
|
||||||
@@ -8296,17 +8326,37 @@ input.ge-ctl-check:focus-visible {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
white-space: nowrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.ge-editor-footer::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.ge-editor-footer .ge-status-tool,
|
.ge-editor-footer .ge-status-tool,
|
||||||
.ge-editor-footer .ge-status-model,
|
.ge-editor-footer .ge-status-model,
|
||||||
.ge-editor-footer .ge-canvas-size {
|
.ge-editor-footer .ge-canvas-size {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.ge-footer-history { margin-right: 2px; }
|
.ge-footer-history {
|
||||||
.ge-footer-zoom { gap: 3px; min-width: 0; }
|
margin-right: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ge-footer-zoom {
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
.ge-footer-zoom > .ge-btn,
|
.ge-footer-zoom > .ge-btn,
|
||||||
.ge-footer-history > .ge-btn { min-width: 26px; }
|
.ge-footer-history > .ge-btn { min-width: 26px; }
|
||||||
|
.ge-setup-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
/* Float the footer above the peek/expanded layers sheet so its zoom/setup
|
/* Float the footer above the peek/expanded layers sheet so its zoom/setup
|
||||||
controls stay tappable (position/left/right/bottom set in JS). */
|
controls stay tappable (position/left/right/bottom set in JS). */
|
||||||
.ge-editor-footer.ge-footer-floating {
|
.ge-editor-footer.ge-footer-floating {
|
||||||
@@ -8317,13 +8367,42 @@ input.ge-ctl-check:focus-visible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phones: the floating zoom/Setup footer collides with the peek/expanded
|
/* Mobile layers header layout */
|
||||||
layers sheet and the Quick Edit card, so remove it entirely on phones.
|
|
||||||
Undo/Redo/History already live in the layers-sheet header, and pinch-zoom
|
|
||||||
replaces the zoom buttons. */
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.ge-editor-footer {
|
.ge-layers-header {
|
||||||
display: none !important;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
white-space: nowrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
.ge-layers-header::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ge-layers-header .ge-footer-zoom {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ge-layers-header .ge-footer-zoom > .ge-btn,
|
||||||
|
.ge-layers-header .ge-footer-history > .ge-btn {
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
.ge-layers-header .ge-setup-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--panel) 85%, var(--fg) 15%);
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
|
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
|
||||||
every other editor asset (sheet headers, tool rail, popups) while lifted. */
|
every other editor asset (sheet headers, tool rail, popups) while lifted. */
|
||||||
@@ -8556,6 +8635,13 @@ body.ge-editor-open .toast.toast-transform-hint {
|
|||||||
position: relative;
|
position: relative;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
.ge-toolbar-wrap.ge-flyout-open {
|
||||||
|
z-index: 250;
|
||||||
|
}
|
||||||
|
.ge-toolbar.ge-flyout-open .ge-toolbar-scroll {
|
||||||
|
-webkit-mask-image: none !important;
|
||||||
|
mask-image: none !important;
|
||||||
|
}
|
||||||
.ge-toolbar-wrap > .ge-toolbar { flex: 1 1 auto; min-width: 0; }
|
.ge-toolbar-wrap > .ge-toolbar { flex: 1 1 auto; min-width: 0; }
|
||||||
.ge-tool-more {
|
.ge-tool-more {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -8579,6 +8665,7 @@ body.ge-editor-open .toast.toast-transform-hint {
|
|||||||
/* Keep the floating editor footer from stacking on the Quick Edit card. The
|
/* Keep the floating editor footer from stacking on the Quick Edit card. The
|
||||||
card owns the bottom of the canvas while it is open, so the footer (zoom /
|
card owns the bottom of the canvas while it is open, so the footer (zoom /
|
||||||
Setup / AI settings) hides until it closes. */
|
Setup / AI settings) hides until it closes. */
|
||||||
|
body.ge-qe-open .ge-editor-footer.ge-footer-floating,
|
||||||
body:has(.ge-ai-command:not(.ge-ai-command-collapsed)) .ge-editor-footer.ge-footer-floating {
|
body:has(.ge-ai-command:not(.ge-ai-command-collapsed)) .ge-editor-footer.ge-footer-floating {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -8657,16 +8744,14 @@ body.ge-editor-open .toast.toast-transform-hint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phone menu bar: tighten the Photoshop menus and keep the Save control
|
/* Phone menu bar: tighten the Photoshop menus. The compact topbar already
|
||||||
icon-only so the twelve menus + Import + Save are not crowded. */
|
drops the Save action label; the draft status stays visible because it is
|
||||||
|
the save button's only announced (role="status") save state. */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.ge-topbar-left .ge-stacked-btn {
|
.ge-topbar-left .ge-stacked-btn {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 0 6px !important;
|
padding: 0 6px !important;
|
||||||
}
|
}
|
||||||
.ge-topbar.ge-topbar-compact #ge-draft-status {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phone editor: dock the zoom/Setup footer flush to the Layers sheet. The
|
/* Phone editor: dock the zoom/Setup footer flush to the Layers sheet. The
|
||||||
@@ -9675,12 +9760,15 @@ body.ge-editor-open .tour-hint {
|
|||||||
}
|
}
|
||||||
/* The dropdowns clip overflow to draw the custom scroll thumb; release the
|
/* The dropdowns clip overflow to draw the custom scroll thumb; release the
|
||||||
clip while a flyout is open/hovered so it can extend past the menu edge. */
|
clip while a flyout is open/hovered so it can extend past the menu edge. */
|
||||||
|
.ge-filter-menu.ge-flyout-open,
|
||||||
.ge-filter-menu:has(.ge-menu-parent.open),
|
.ge-filter-menu:has(.ge-menu-parent.open),
|
||||||
.ge-filter-menu:has(.ge-menu-parent:hover),
|
.ge-filter-menu:has(.ge-menu-parent:hover),
|
||||||
.ge-filter-menu:has(.ge-submenu:hover),
|
.ge-filter-menu:has(.ge-submenu:hover),
|
||||||
|
.ge-image-menu.ge-flyout-open,
|
||||||
.ge-image-menu:has(.ge-menu-parent.open),
|
.ge-image-menu:has(.ge-menu-parent.open),
|
||||||
.ge-image-menu:has(.ge-menu-parent:hover),
|
.ge-image-menu:has(.ge-menu-parent:hover),
|
||||||
.ge-image-menu:has(.ge-submenu:hover),
|
.ge-image-menu:has(.ge-submenu:hover),
|
||||||
|
.ge-selection-menu.ge-flyout-open,
|
||||||
.ge-selection-menu:has(.ge-menu-parent.open),
|
.ge-selection-menu:has(.ge-menu-parent.open),
|
||||||
.ge-selection-menu:has(.ge-menu-parent:hover),
|
.ge-selection-menu:has(.ge-menu-parent:hover),
|
||||||
.ge-selection-menu:has(.ge-submenu:hover) {
|
.ge-selection-menu:has(.ge-submenu:hover) {
|
||||||
@@ -9689,6 +9777,7 @@ body.ge-editor-open .tour-hint {
|
|||||||
mask-image: none;
|
mask-image: none;
|
||||||
}
|
}
|
||||||
/* The Window menu's Workspace flyout needs the same clip release. */
|
/* The Window menu's Workspace flyout needs the same clip release. */
|
||||||
|
.ge-window-menu.ge-flyout-open,
|
||||||
.ge-window-menu:has(.ge-menu-parent.open),
|
.ge-window-menu:has(.ge-menu-parent.open),
|
||||||
.ge-window-menu:has(.ge-menu-parent:hover),
|
.ge-window-menu:has(.ge-menu-parent:hover),
|
||||||
.ge-window-menu:has(.ge-submenu:hover) {
|
.ge-window-menu:has(.ge-submenu:hover) {
|
||||||
|
|||||||
@@ -2690,11 +2690,14 @@ body.theme-frosted .modal {
|
|||||||
max-height: 95dvh !important;
|
max-height: 95dvh !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notes and Documents must respect the same viewport and safe-area
|
||||||
|
top as other mobile full-height sheets. Keep these authoritative rules
|
||||||
|
consistent with 05-documents.css and 14-email.css. */
|
||||||
body.doc-view .doc-editor-pane,
|
body.doc-view .doc-editor-pane,
|
||||||
body.notes-view .notes-pane {
|
body.notes-view .notes-pane {
|
||||||
inset: auto 0 0 !important;
|
inset: var(--sheet-top) 0 0 !important;
|
||||||
height: 95dvh !important;
|
height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||||
max-height: 95dvh !important;
|
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-8
@@ -16,13 +16,6 @@
|
|||||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
<script nonce="{{CSP_NONCE}}">
|
<script nonce="{{CSP_NONCE}}">
|
||||||
window._odysseusLoadTime = Date.now();
|
window._odysseusLoadTime = Date.now();
|
||||||
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
|
|
||||||
(function(){
|
|
||||||
var ua = navigator.userAgent || '';
|
|
||||||
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
|
|
||||||
var vp = document.querySelector('meta[name="viewport"]');
|
|
||||||
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
|
|
||||||
})();
|
|
||||||
(function(){
|
(function(){
|
||||||
try {
|
try {
|
||||||
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
||||||
@@ -257,7 +250,7 @@
|
|||||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261017titlefade1">
|
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
||||||
|
|||||||
@@ -771,7 +771,13 @@ function _getModal() {
|
|||||||
<button class="cal-side-nav cal-side-nav-next" id="cal-side-next" title="Next" aria-label="Next">›</button>
|
<button class="cal-side-nav cal-side-nav-next" id="cal-side-next" title="Next" aria-label="Next">›</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(_modal);
|
document.body.appendChild(_modal);
|
||||||
_modal.querySelector('#cal-close').addEventListener('click', closeCalendar);
|
_modal.querySelector('#cal-close').addEventListener('click', () => {
|
||||||
|
if (_modal.querySelector('.cal-form')) {
|
||||||
|
_render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeCalendar();
|
||||||
|
});
|
||||||
_modal.querySelector('#cal-side-prev')?.addEventListener('click', (e) => {
|
_modal.querySelector('#cal-side-prev')?.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
+246
-29
@@ -44,34 +44,187 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
|
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
|
||||||
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
||||||
|
|
||||||
// URL of the photo attached to the current turn (chat uploads are served at
|
// Inline chat images share the editor's 8192px dimension ceiling, with a
|
||||||
// /api/upload/<id>). Used by the "Open in editor & apply" action.
|
// conservative 16MP budget for its multiple canvas surfaces. No blob URLs are
|
||||||
function _currentAttachedImageUrl() {
|
// needed here: chat previews use server-authorized upload references.
|
||||||
try {
|
const _MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
|
||||||
const box = document.getElementById('chat-history');
|
const _MAX_INLINE_BYTES = 7 * 1024 * 1024;
|
||||||
if (!box) return null;
|
const _MAX_IMAGE_DIMENSION = 8192;
|
||||||
// Use the newest user bubble that carries an image preview. Note
|
const _MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||||
// ':last-of-type' is wrong here (it selects the last sibling of the
|
let _inlineRasterCache = null; // One bounded source; repeated actions reuse its decode.
|
||||||
// element type, and the assistant reply follows the user bubble), so
|
|
||||||
// walk the user bubbles from the end instead.
|
function _imageDimensionsAllowed(width, height) {
|
||||||
const users = box.querySelectorAll('.msg-user');
|
return width > 0 && height > 0 && width <= _MAX_IMAGE_DIMENSION &&
|
||||||
for (let i = users.length - 1; i >= 0; i--) {
|
height <= _MAX_IMAGE_DIMENSION && width * height <= _MAX_IMAGE_PIXELS;
|
||||||
const img = users[i].querySelector('.attach-image-preview[data-file-id] img');
|
}
|
||||||
if (img && img.getAttribute('src')) return img.getAttribute('src');
|
|
||||||
}
|
function _rasterDimensions(bytes, mime) {
|
||||||
// Fall back to any attachment preview/card in the history (newest).
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
const previews = box.querySelectorAll(
|
const ascii = (offset, text) => [...text].every((char, i) => bytes[offset + i] === char.charCodeAt(0));
|
||||||
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
|
if (mime === 'image/png' && bytes.length >= 33 &&
|
||||||
);
|
bytes[0] === 137 && ascii(1, 'PNG\r\n\x1a\n') && view.getUint32(8) === 13 && ascii(12, 'IHDR')) {
|
||||||
if (previews.length) {
|
return [view.getUint32(16), view.getUint32(20)];
|
||||||
const last = previews[previews.length - 1];
|
|
||||||
const id = last.dataset ? last.dataset.fileId : null;
|
|
||||||
if (id) return API_BASE + '/api/upload/' + id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
if (mime === 'image/gif' && bytes.length >= 13 && (ascii(0, 'GIF87a') || ascii(0, 'GIF89a'))) {
|
||||||
|
return [view.getUint16(6, true), view.getUint16(8, true)];
|
||||||
|
}
|
||||||
|
if (mime === 'image/jpeg' && bytes[0] === 255 && bytes[1] === 216) {
|
||||||
|
let pos = 2;
|
||||||
|
while (pos + 4 <= bytes.length) {
|
||||||
|
if (bytes[pos++] !== 255) return null;
|
||||||
|
while (bytes[pos] === 255) pos++;
|
||||||
|
const marker = bytes[pos++];
|
||||||
|
if (marker === 218 || marker === 217) break;
|
||||||
|
if (marker === 1 || (marker >= 208 && marker <= 215)) continue;
|
||||||
|
if (pos + 2 > bytes.length) return null;
|
||||||
|
const size = view.getUint16(pos);
|
||||||
|
if (size < 2 || pos + size > bytes.length) return null;
|
||||||
|
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker) && size >= 8) {
|
||||||
|
return [view.getUint16(pos + 5), view.getUint16(pos + 3)];
|
||||||
|
}
|
||||||
|
pos += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mime === 'image/webp' && bytes.length >= 30 && ascii(0, 'RIFF') &&
|
||||||
|
view.getUint32(4, true) + 8 === bytes.length && ascii(8, 'WEBP')) {
|
||||||
|
if (ascii(12, 'VP8X')) return [1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16), 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16)];
|
||||||
|
if (ascii(12, 'VP8L') && bytes[20] === 47) return [1 + ((bytes[21] | (bytes[22] << 8)) & 16383), 1 + (((bytes[22] >> 6) | (bytes[23] << 2) | (bytes[24] << 10)) & 16383)];
|
||||||
|
if (ascii(12, 'VP8 ') && bytes[23] === 157 && bytes[24] === 1 && bytes[25] === 42) return [view.getUint16(26, true) & 16383, view.getUint16(28, true) & 16383];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _inlineRaster(url) {
|
||||||
|
if (_inlineRasterCache?.url === url) return _inlineRasterCache;
|
||||||
|
const result = { url, valid: false };
|
||||||
|
_inlineRasterCache = result;
|
||||||
|
if (url.length > _MAX_DATA_URL_LENGTH) return result;
|
||||||
|
const match = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,/i.exec(url);
|
||||||
|
if (!match) return result;
|
||||||
|
const payload = url.slice(match[0].length);
|
||||||
|
if (!payload || payload.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) return result;
|
||||||
|
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
|
||||||
|
const size = payload.length / 4 * 3 - padding;
|
||||||
|
if (size > _MAX_INLINE_BYTES) return result;
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
if (padding && (alphabet.indexOf(payload[payload.length - padding - 1]) & (padding === 2 ? 15 : 3))) return result;
|
||||||
|
try {
|
||||||
|
const bytes = typeof Uint8Array.fromBase64 === 'function' ? Uint8Array.fromBase64(payload) : (() => {
|
||||||
|
const binary = atob(payload); const array = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
|
||||||
|
return array;
|
||||||
|
})();
|
||||||
|
const mime = match[1].toLowerCase().replace('image/jpg', 'image/jpeg');
|
||||||
|
const dimensions = _rasterDimensions(bytes, mime);
|
||||||
|
if (!dimensions || !_imageDimensionsAllowed(...dimensions)) return result;
|
||||||
|
Object.assign(result, { valid: true, bytes, mime, dimensions });
|
||||||
|
} catch (_) {}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This synchronous guard rejects untrusted references before any fetch/decode.
|
||||||
|
// Inline images additionally require a successful browser decode before launch.
|
||||||
|
function _isAuthorizedImageUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') return false;
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (trimmed.startsWith('data:')) return _inlineRaster(trimmed).valid;
|
||||||
|
if (!trimmed || trimmed.startsWith('blob:') || trimmed.startsWith('//')) return false;
|
||||||
|
try {
|
||||||
|
const base = window.location.origin;
|
||||||
|
const parsed = new URL(trimmed, base);
|
||||||
|
return parsed.origin === base && !parsed.search && !parsed.hash &&
|
||||||
|
/^\/api\/(?:upload|gallery)\/[A-Za-z0-9_\-]+(?:\.[a-zA-Z0-9]+)?$/.test(parsed.pathname);
|
||||||
|
} catch (_) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _validateInlineImage(url) {
|
||||||
|
const raster = _inlineRaster(url.trim());
|
||||||
|
if (!raster.valid) return false;
|
||||||
|
if (!raster.decoded) raster.decoded = (async () => {
|
||||||
|
try {
|
||||||
|
const image = await createImageBitmap(new Blob([raster.bytes], { type: raster.mime }));
|
||||||
|
try {
|
||||||
|
return _imageDimensionsAllowed(image.width, image.height) &&
|
||||||
|
image.width * image.height === raster.dimensions[0] * raster.dimensions[1];
|
||||||
|
} finally { image.close(); }
|
||||||
|
} catch (_) { return false; }
|
||||||
|
finally { raster.bytes = null; }
|
||||||
|
})();
|
||||||
|
return raster.decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the specific user message that originated the turn containing container.
|
||||||
|
function _findOriginatingUserMessage(container) {
|
||||||
|
if (!container) return null;
|
||||||
|
let curr = container.previousElementSibling;
|
||||||
|
while (curr) {
|
||||||
|
if (curr.classList && curr.classList.contains('msg-user')) {
|
||||||
|
return curr;
|
||||||
|
}
|
||||||
|
curr = curr.previousElementSibling;
|
||||||
|
}
|
||||||
|
const box = document.getElementById('chat-history');
|
||||||
|
if (box && box.contains(container)) {
|
||||||
|
const all = Array.from(box.querySelectorAll('.msg, .agent-thread, .ai-msg-wrap, .round-holder'));
|
||||||
|
const idx = all.findIndex((el) => el === container || el.contains(container));
|
||||||
|
if (idx > 0) {
|
||||||
|
for (let i = idx - 1; i >= 0; i--) {
|
||||||
|
if (all[i].classList && all[i].classList.contains('msg-user')) {
|
||||||
|
return all[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the image attached to the exact turn associated with container / action.
|
||||||
|
// Never falls back to unrelated images from earlier conversation turns.
|
||||||
|
function _attachedImageForTurn(container, action) {
|
||||||
|
const explicitId = action && (action.imageId || action.image_id || action.fileId || action.file_id);
|
||||||
|
if (explicitId && typeof explicitId === 'string' && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(explicitId)) {
|
||||||
|
return (API_BASE || '') + '/api/upload/' + explicitId;
|
||||||
|
}
|
||||||
|
const explicitUrl = action && (action.imageUrl || action.image_url);
|
||||||
|
if (explicitUrl) return _isAuthorizedImageUrl(explicitUrl) ? explicitUrl : null;
|
||||||
|
|
||||||
|
const userMsg = _findOriginatingUserMessage(container);
|
||||||
|
if (!userMsg) return null;
|
||||||
|
|
||||||
|
const previews = userMsg.querySelectorAll(
|
||||||
|
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
|
||||||
|
);
|
||||||
|
for (let i = previews.length - 1; i >= 0; i--) {
|
||||||
|
const fid = previews[i].dataset ? previews[i].dataset.fileId : null;
|
||||||
|
if (fid && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(fid)) {
|
||||||
|
return (API_BASE || '') + '/api/upload/' + fid;
|
||||||
|
}
|
||||||
|
const img = previews[i].querySelector('img');
|
||||||
|
const src = img ? (img.getAttribute('src') || '') : '';
|
||||||
|
if (_isAuthorizedImageUrl(src)) return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgs = userMsg.querySelectorAll('.attach-image-preview img, .attach-card img');
|
||||||
|
for (let i = imgs.length - 1; i >= 0; i--) {
|
||||||
|
const src = imgs[i].getAttribute('src') || '';
|
||||||
|
if (_isAuthorizedImageUrl(src)) return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _currentAttachedImageUrl(container, action) {
|
||||||
|
return _attachedImageForTurn(container, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _notifyError(msg) {
|
||||||
|
try {
|
||||||
|
if (uiModule && typeof uiModule.showError === 'function') {
|
||||||
|
uiModule.showError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
console.warn('[chat]', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the one-click "Open in editor & apply" button on a photo-edit reply
|
// Render the one-click "Open in editor & apply" button on a photo-edit reply
|
||||||
@@ -85,25 +238,89 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
btn.textContent = 'Open in editor & apply';
|
btn.textContent = 'Open in editor & apply';
|
||||||
btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;';
|
btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;';
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
|
if (btn.disabled) return;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
|
let imageUrl = _attachedImageForTurn(container, action);
|
||||||
|
if (!imageUrl) {
|
||||||
|
_notifyError('No attached photo found for this conversation turn.');
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isAuthorizedImageUrl(imageUrl)) {
|
||||||
|
_notifyError('Unauthorized or invalid image reference.');
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageUrl.trim().startsWith('data:') && !await _validateInlineImage(imageUrl)) {
|
||||||
|
_notifyError('Invalid or oversized image attachment.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Gallery references name an owner-scoped metadata resource, not image bytes.
|
||||||
|
if (!imageUrl.trim().startsWith('data:') && new URL(imageUrl, window.location.origin).pathname.startsWith('/api/gallery/')) {
|
||||||
|
const response = await fetch(imageUrl, { credentials: 'same-origin' });
|
||||||
|
if (!response.ok) { _notifyError('The gallery image is unavailable.'); return; }
|
||||||
|
const image = await response.json();
|
||||||
|
const source = new URL(image.url || '', window.location.origin);
|
||||||
|
if (source.origin !== window.location.origin || source.search || source.hash ||
|
||||||
|
!/^\/api\/generated-image\/[A-Za-z0-9_.-]+$/.test(source.pathname)) {
|
||||||
|
_notifyError('Invalid gallery image reference.'); return;
|
||||||
|
}
|
||||||
|
imageUrl = source.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify attachment is accessible and authorized on the server before launching editor
|
||||||
|
if (imageUrl.includes('/api/upload/')) {
|
||||||
|
try {
|
||||||
|
const probe = await fetch(imageUrl, { method: 'HEAD' });
|
||||||
|
if (!probe.ok) {
|
||||||
|
if (probe.status === 404 || probe.status === 410) {
|
||||||
|
_notifyError('The attached image is no longer available or upload has expired.');
|
||||||
|
} else if (probe.status === 401 || probe.status === 403) {
|
||||||
|
_notifyError('You are not authorized to edit this attached image.');
|
||||||
|
} else {
|
||||||
|
_notifyError('Could not verify image attachment authorization.');
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_notifyError('Could not verify image attachment authorization.');
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window.gePhotoEditorApply !== 'function') {
|
if (typeof window.gePhotoEditorApply !== 'function') {
|
||||||
await import('/static/js/galleryEditor.js?v=20260909movepicklayer1');
|
await import('/static/js/galleryEditor.js?v=20260909movepicklayer1');
|
||||||
}
|
}
|
||||||
await window.gePhotoEditorApply({
|
await window.gePhotoEditorApply({
|
||||||
imageId: null,
|
imageId: (action && (action.imageId || action.image_id)) || null,
|
||||||
imageUrl: _currentAttachedImageUrl(),
|
imageUrl,
|
||||||
prompt: action.prompt || '',
|
prompt: action.prompt || '',
|
||||||
displayName: 'Chat edit',
|
displayName: 'Chat edit',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[chat] open_and_apply failed', err);
|
console.error('[chat] open_and_apply failed', err);
|
||||||
|
_notifyError('Failed to apply edit: ' + (err.message || err));
|
||||||
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__chatPhotoEditorTestHooks = {
|
||||||
|
_isAuthorizedImageUrl,
|
||||||
|
_findOriginatingUserMessage,
|
||||||
|
_attachedImageForTurn,
|
||||||
|
renderPhotoEditorApplyButton,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let API_BASE = '';
|
let API_BASE = '';
|
||||||
let currentAbort = null;
|
let currentAbort = null;
|
||||||
let isStreaming = false;
|
let isStreaming = false;
|
||||||
|
|||||||
@@ -3277,7 +3277,6 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
let lastWrap = null;
|
let lastWrap = null;
|
||||||
let firstMsgAi = null;
|
let firstMsgAi = null;
|
||||||
let lastMsgAi = null;
|
let lastMsgAi = null;
|
||||||
const newNodes = [];
|
|
||||||
|
|
||||||
const toolsByRound = {};
|
const toolsByRound = {};
|
||||||
let lastPrivateBrowserUrl = '';
|
let lastPrivateBrowserUrl = '';
|
||||||
@@ -3371,7 +3370,6 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
wrap.dataset.raw = txt;
|
wrap.dataset.raw = txt;
|
||||||
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
|
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
|
||||||
box.appendChild(wrap);
|
box.appendChild(wrap);
|
||||||
newNodes.push(wrap);
|
|
||||||
lastWrap = wrap;
|
lastWrap = wrap;
|
||||||
if (!firstMsgAi) firstMsgAi = wrap;
|
if (!firstMsgAi) firstMsgAi = wrap;
|
||||||
lastMsgAi = wrap;
|
lastMsgAi = wrap;
|
||||||
@@ -3389,7 +3387,6 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
// Extend line up if there's a chat bubble above
|
// Extend line up if there's a chat bubble above
|
||||||
if (txt) threadWrap.classList.add('has-top');
|
if (txt) threadWrap.classList.add('has-top');
|
||||||
box.appendChild(threadWrap);
|
box.appendChild(threadWrap);
|
||||||
newNodes.push(threadWrap);
|
|
||||||
}
|
}
|
||||||
for (const ev of roundTools) {
|
for (const ev of roundTools) {
|
||||||
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
|
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
|
||||||
@@ -3451,7 +3448,6 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
if (image) {
|
if (image) {
|
||||||
const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id);
|
const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id);
|
||||||
box.appendChild(imgBubble);
|
box.appendChild(imgBubble);
|
||||||
newNodes.push(imgBubble);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-18
@@ -72,7 +72,12 @@ function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g
|
|||||||
|
|
||||||
// ── Storage ───────────────────────────────────────────────────────────
|
// ── Storage ───────────────────────────────────────────────────────────
|
||||||
function getRecents() {
|
function getRecents() {
|
||||||
try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); }
|
try {
|
||||||
|
const values = JSON.parse(localStorage.getItem(LS_RECENT) || '[]');
|
||||||
|
return Array.isArray(values)
|
||||||
|
? values.filter(value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)).slice(0, MAX_RECENT)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
catch { return []; }
|
catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,32 +90,28 @@ function addRecent(hex) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Suggestions based on current color (5 harmony swatches) ──────────
|
// ── Suggestions based on current color (5 harmony swatches) ──────────
|
||||||
function computeSuggestions() {
|
function suggestionsForHsv(h, s, v) {
|
||||||
// Complement, analogous ±30°, split-complement (+150), tone shift
|
// Complement, analogous ±30°, split-complement (+150), tone shift
|
||||||
return [
|
|
||||||
{ hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' },
|
|
||||||
{ hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' },
|
|
||||||
{ hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' },
|
|
||||||
{ hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' },
|
|
||||||
{ hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Reusable helpers for hosts that surface swatches inline ──────────
|
|
||||||
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
|
|
||||||
export function getRecentColors() { return getRecents(); }
|
|
||||||
export function recordRecentColor(hex) { addRecent(hex); }
|
|
||||||
export function suggestColorsFor(hex) {
|
|
||||||
const { h, s, v } = hexToHsv(hex || '#000000');
|
|
||||||
return [
|
return [
|
||||||
{ hex: hsvToHex(h + 180, s, v), label: 'Complement' },
|
{ hex: hsvToHex(h + 180, s, v), label: 'Complement' },
|
||||||
{ hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
|
{ hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
|
||||||
{ hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
|
{ hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
|
||||||
{ hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
|
{ hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
|
||||||
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
|
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Reusable helpers for hosts that surface swatches inline ──────────
|
||||||
|
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
|
||||||
|
function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); }
|
||||||
|
|
||||||
|
export function getRecentColors() { return getRecents(); }
|
||||||
|
export function recordRecentColor(hex) { addRecent(hex); }
|
||||||
|
export function suggestColorsFor(hex) {
|
||||||
|
const { h, s, v } = hexToHsv(hex || '#000000');
|
||||||
|
return suggestionsForHsv(h, s, v);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Popover build ─────────────────────────────────────────────────────
|
// ── Popover build ─────────────────────────────────────────────────────
|
||||||
function buildPopover() {
|
function buildPopover() {
|
||||||
const p = document.createElement('div');
|
const p = document.createElement('div');
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
function streamError(payload) {
|
||||||
|
const detail = payload?.data ?? payload;
|
||||||
|
const candidates = [detail?.message, detail?.text, detail?.error?.message,
|
||||||
|
detail?.error, payload?.message, payload?.text, payload?.error?.message,
|
||||||
|
payload?.error, typeof payload === 'string' ? payload : null];
|
||||||
|
const message = candidates.find(value => typeof value === 'string' && value.trim());
|
||||||
|
return new Error(message || 'Model request failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode complete SSE frames, requiring the backend's terminal DONE event. */
|
||||||
|
export async function consumeCompareEvents(response, onEvent, onActivity) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let line = '', event = 'message', data = [], skipLF = false;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (!done) onActivity?.();
|
||||||
|
let chunk = decoder.decode(value, { stream: !done });
|
||||||
|
if (skipLF && chunk.length) {
|
||||||
|
if (chunk.startsWith('\n')) chunk = chunk.slice(1);
|
||||||
|
skipLF = false;
|
||||||
|
}
|
||||||
|
const separators = /\r\n|\r|\n/g;
|
||||||
|
let start = 0, separator;
|
||||||
|
while ((separator = separators.exec(chunk))) {
|
||||||
|
line += chunk.slice(start, separator.index);
|
||||||
|
start = separator.index + separator[0].length;
|
||||||
|
skipLF = separator[0] === '\r' && start === chunk.length;
|
||||||
|
if (!line) {
|
||||||
|
if (data.length || event === 'error') {
|
||||||
|
const raw = data.join('\n');
|
||||||
|
if (event !== 'error' && raw === '[DONE]') return;
|
||||||
|
let payload;
|
||||||
|
try { payload = JSON.parse(raw); }
|
||||||
|
catch (_) {
|
||||||
|
if (event === 'error') throw streamError(raw);
|
||||||
|
throw new Error('Invalid model stream data.');
|
||||||
|
}
|
||||||
|
if (event === 'error' || payload?.type === 'error') throw streamError(payload);
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||||
|
throw new Error('Invalid model stream data.');
|
||||||
|
}
|
||||||
|
await onEvent(payload);
|
||||||
|
}
|
||||||
|
event = 'message';
|
||||||
|
data = [];
|
||||||
|
} else if (!line.startsWith(':')) {
|
||||||
|
const colon = line.indexOf(':');
|
||||||
|
const field = colon < 0 ? line : line.slice(0, colon);
|
||||||
|
let value = colon < 0 ? '' : line.slice(colon + 1);
|
||||||
|
if (value.startsWith(' ')) value = value.slice(1);
|
||||||
|
if (field === 'data') data.push(value);
|
||||||
|
else if (field === 'event') event = value || 'message';
|
||||||
|
}
|
||||||
|
line = '';
|
||||||
|
}
|
||||||
|
line += chunk.slice(start);
|
||||||
|
if (done) throw new Error('Stream ended before completion.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { await reader.cancel(); } catch (_) {}
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-22
@@ -1,5 +1,6 @@
|
|||||||
// compare/stream.js — SSE streaming to panes
|
// compare/stream.js — SSE streaming to panes
|
||||||
import state from './state.js';
|
import state from './state.js';
|
||||||
|
import { consumeCompareEvents } from './sse.js';
|
||||||
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
|
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
|
||||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1';
|
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1';
|
||||||
import markdownModule from '../markdown.js';
|
import markdownModule from '../markdown.js';
|
||||||
@@ -472,25 +473,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
await consumeCompareEvents(response,
|
||||||
const decoder = new TextDecoder();
|
(json) => {
|
||||||
let buffer = '';
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
_resetIdleTimeout(); // any chunk = stream is alive
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split('\n');
|
|
||||||
buffer = lines.pop() || '';
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.startsWith('data: ')) continue;
|
|
||||||
const data = line.slice(6);
|
|
||||||
if (data === '[DONE]') break;
|
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(data);
|
|
||||||
if (json.type === 'metrics') {
|
if (json.type === 'metrics') {
|
||||||
metrics = json.data;
|
metrics = json.data;
|
||||||
|
|
||||||
@@ -672,7 +657,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||||
} else if (json.delta) {
|
} else if (json.delta) {
|
||||||
// Skip text deltas if we already rendered an image
|
// Skip text deltas if we already rendered an image
|
||||||
if (aiMsgEl._imageData) continue;
|
if (aiMsgEl._imageData) return;
|
||||||
// Capture TTFT on very first text delta
|
// Capture TTFT on very first text delta
|
||||||
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
|
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
|
||||||
// On first delta, destroy spinner and prepare text area
|
// On first delta, destroy spinner and prepare text area
|
||||||
@@ -701,8 +686,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
_scheduleLiveRender(target);
|
_scheduleLiveRender(target);
|
||||||
}
|
}
|
||||||
} catch (e) { console.warn('Compare stream render error:', e); }
|
} catch (e) { console.warn('Compare stream render error:', e); }
|
||||||
}
|
}, _resetIdleTimeout);
|
||||||
}
|
|
||||||
|
|
||||||
streamOk = true;
|
streamOk = true;
|
||||||
// Destroy any remaining spinner
|
// Destroy any remaining spinner
|
||||||
@@ -900,9 +884,15 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Compare stream error:', error);
|
console.error('Compare stream error:', error);
|
||||||
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(error.message) + '</span>';
|
awaitingChoice = false;
|
||||||
|
const message = state._blindMode ? 'Model request failed. Try again.' : error.message;
|
||||||
|
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(message) + '</span>';
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (aiMsgEl._spinner) {
|
||||||
|
aiMsgEl._spinner.destroy();
|
||||||
|
aiMsgEl._spinner = null;
|
||||||
|
}
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
_timerDone = true;
|
_timerDone = true;
|
||||||
cancelAnimationFrame(_rafId);
|
cancelAnimationFrame(_rafId);
|
||||||
|
|||||||
@@ -1195,11 +1195,12 @@ async function _fetchDependencies() {
|
|||||||
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
|
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
|
||||||
// append a caret button that toggles a per-row recipe panel below.
|
// append a caret button that toggles a per-row recipe panel below.
|
||||||
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
|
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
|
||||||
// Standalone recipe-caret button removed — the "Pick install
|
// The caret is the only way to open the recipe panel (the Installed ▾
|
||||||
// command" action lives inside the Installed ▾ dropdown menu
|
// menu has no recipe item), and the HW Fit / serve-card deep links
|
||||||
// (see _showDepMenu) so each row only has ONE caret to click.
|
// open it by clicking this caret (#6747).
|
||||||
// Kept the variable so downstream concat code stays the same.
|
const recipeCaret = hasRecipe
|
||||||
const recipeCaret = '';
|
? `<button class="cookbook-dep-tag cookbook-dep-recipe-caret" data-dep-recipe-toggle="${esc(pkg.name)}" title="Pick a model to see the exact install commands" aria-expanded="false" style="background:none;border:1px solid var(--border);padding:2px 6px;display:inline-flex;align-items:center;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg></button>`
|
||||||
|
: '';
|
||||||
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
|
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
|
||||||
// When llama_cpp (or any future engine) reports build_deps_missing
|
// When llama_cpp (or any future engine) reports build_deps_missing
|
||||||
// from its system_prereqs probe, surface a one-tap install button
|
// from its system_prereqs probe, surface a one-tap install button
|
||||||
|
|||||||
+44
-7
@@ -1996,6 +1996,17 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
// Active drop mode for the PDF toolbar — toolbar buttons set this; the
|
// Active drop mode for the PDF toolbar — toolbar buttons set this; the
|
||||||
// next click on a page consumes it. null means clicks do nothing.
|
// next click on a page consumes it. null means clicks do nothing.
|
||||||
let _pdfDropMode = null;
|
let _pdfDropMode = null;
|
||||||
|
let _pdfAnnotationDragging = false;
|
||||||
|
function _setPdfAnnotationDragging(dragging) {
|
||||||
|
_pdfAnnotationDragging = dragging;
|
||||||
|
for (const ref of _pdfPaneAnnotationsByDoc.get(activeDocId) || []) {
|
||||||
|
ref._refreshHandles?.();
|
||||||
|
if (dragging) {
|
||||||
|
const menu = ref.wrap?.querySelector('.pdf-annotation-text-menu');
|
||||||
|
if (menu) menu.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Per-doc last-used line spacing for text annotations. Once the user picks
|
// Per-doc last-used line spacing for text annotations. Once the user picks
|
||||||
// 1.6 for one box, every text box dropped after that defaults to 1.6.
|
// 1.6 for one box, every text box dropped after that defaults to 1.6.
|
||||||
const _pdfLastLineHeight = new Map(); // docId -> number
|
const _pdfLastLineHeight = new Map(); // docId -> number
|
||||||
@@ -2290,8 +2301,16 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
// active (toolbar buttons set the mode). Without a mode, clicking does
|
// active (toolbar buttons set the mode). Without a mode, clicking does
|
||||||
// nothing — keeps page interactions predictable so users don't get
|
// nothing — keeps page interactions predictable so users don't get
|
||||||
// surprise boxes from stray clicks.
|
// surprise boxes from stray clicks.
|
||||||
|
const isBlankPageTarget = target => target === pageWrap
|
||||||
|
|| (target?.tagName === 'IMG' && target.parentElement === pageWrap);
|
||||||
|
let placementStartedOnPage = false;
|
||||||
|
pageWrap.addEventListener('pointerdown', ev => {
|
||||||
|
placementStartedOnPage = isBlankPageTarget(ev.target);
|
||||||
|
}, true);
|
||||||
pageWrap.addEventListener('click', (ev) => {
|
pageWrap.addEventListener('click', (ev) => {
|
||||||
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return;
|
const canPlace = placementStartedOnPage;
|
||||||
|
placementStartedOnPage = false;
|
||||||
|
if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return;
|
||||||
if (!_pdfDropMode) return;
|
if (!_pdfDropMode) return;
|
||||||
const rect = pageWrap.getBoundingClientRect();
|
const rect = pageWrap.getBoundingClientRect();
|
||||||
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
|
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||||
@@ -2423,9 +2442,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
|
|
||||||
// Set handle visibility together; clicking/tapping the annotation itself
|
// Set handle visibility together; clicking/tapping the annotation itself
|
||||||
// brings hidden controls back.
|
// brings hidden controls back.
|
||||||
|
let handlesRequested = _isTouch;
|
||||||
const _setHandlesVisible = (show) => {
|
const _setHandlesVisible = (show) => {
|
||||||
|
if (!_pdfAnnotationDragging) handlesRequested = show;
|
||||||
const dismissed = wrap.dataset.controlsDismissed === '1';
|
const dismissed = wrap.dataset.controlsDismissed === '1';
|
||||||
const v = (show && !dismissed) ? '' : 'none';
|
const v = (show && !dismissed && !_pdfAnnotationDragging) ? '' : 'none';
|
||||||
del.style.display = v;
|
del.style.display = v;
|
||||||
grip.style.display = v;
|
grip.style.display = v;
|
||||||
resize.style.display = v;
|
resize.style.display = v;
|
||||||
@@ -2446,7 +2467,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
_setHandlesVisible(true);
|
_setHandlesVisible(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible };
|
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible,
|
||||||
|
_refreshHandles: () => _setHandlesVisible(handlesRequested) };
|
||||||
|
|
||||||
if (kind === 'check') {
|
if (kind === 'check') {
|
||||||
// Stamp checkmark — value is fixed, nothing to listen for.
|
// Stamp checkmark — value is fixed, nothing to listen for.
|
||||||
@@ -2568,7 +2590,12 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
ref._autoGrow = _autoGrow;
|
ref._autoGrow = _autoGrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
del.addEventListener('pointerdown', ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
});
|
||||||
del.addEventListener('click', (ev) => {
|
del.addEventListener('click', (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
_pushPdfUndoSnapshot();
|
_pushPdfUndoSnapshot();
|
||||||
_removeAnnotation(ref);
|
_removeAnnotation(ref);
|
||||||
@@ -2580,6 +2607,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
_pushPdfUndoSnapshot();
|
_pushPdfUndoSnapshot();
|
||||||
try { grip.setPointerCapture(ev.pointerId); } catch (_) {}
|
try { grip.setPointerCapture(ev.pointerId); } catch (_) {}
|
||||||
|
_setPdfAnnotationDragging(true);
|
||||||
// Hide the × and resize handles while moving so they don't obscure the
|
// Hide the × and resize handles while moving so they don't obscure the
|
||||||
// box — easier to see exactly where it lands. Restored on release.
|
// box — easier to see exactly where it lands. Restored on release.
|
||||||
del.style.display = 'none';
|
del.style.display = 'none';
|
||||||
@@ -2598,11 +2626,14 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('pointermove', onMove);
|
document.removeEventListener('pointermove', onMove);
|
||||||
document.removeEventListener('pointerup', onUp);
|
document.removeEventListener('pointerup', onUp);
|
||||||
|
document.removeEventListener('pointercancel', onUp);
|
||||||
|
_setPdfAnnotationDragging(false);
|
||||||
_setHandlesVisible(true);
|
_setHandlesVisible(true);
|
||||||
_schedulePdfPaneSave();
|
_schedulePdfPaneSave();
|
||||||
};
|
};
|
||||||
document.addEventListener('pointermove', onMove);
|
document.addEventListener('pointermove', onMove);
|
||||||
document.addEventListener('pointerup', onUp);
|
document.addEventListener('pointerup', onUp);
|
||||||
|
document.addEventListener('pointercancel', onUp);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag bottom-right corner to resize. Width/height stored as percentages.
|
// Drag bottom-right corner to resize. Width/height stored as percentages.
|
||||||
@@ -2611,6 +2642,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
_pushPdfUndoSnapshot();
|
_pushPdfUndoSnapshot();
|
||||||
try { resize.setPointerCapture(ev.pointerId); } catch (_) {}
|
try { resize.setPointerCapture(ev.pointerId); } catch (_) {}
|
||||||
|
_setPdfAnnotationDragging(true);
|
||||||
// Hide the × and move handles while resizing — clean view of the box edge.
|
// Hide the × and move handles while resizing — clean view of the box edge.
|
||||||
del.style.display = 'none';
|
del.style.display = 'none';
|
||||||
grip.style.display = 'none';
|
grip.style.display = 'none';
|
||||||
@@ -2628,11 +2660,14 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('pointermove', onMove);
|
document.removeEventListener('pointermove', onMove);
|
||||||
document.removeEventListener('pointerup', onUp);
|
document.removeEventListener('pointerup', onUp);
|
||||||
|
document.removeEventListener('pointercancel', onUp);
|
||||||
|
_setPdfAnnotationDragging(false);
|
||||||
_setHandlesVisible(true);
|
_setHandlesVisible(true);
|
||||||
_schedulePdfPaneSave();
|
_schedulePdfPaneSave();
|
||||||
};
|
};
|
||||||
document.addEventListener('pointermove', onMove);
|
document.addEventListener('pointermove', onMove);
|
||||||
document.addEventListener('pointerup', onUp);
|
document.addEventListener('pointerup', onUp);
|
||||||
|
document.addEventListener('pointercancel', onUp);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Text options menu — opened from the floating … button so the spacing
|
// Text options menu — opened from the floating … button so the spacing
|
||||||
@@ -5197,7 +5232,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function _handleAttachUpload(e) {
|
async function _handleAttachUpload(e) {
|
||||||
const files = e.target.files;
|
const files = Array.from(e.target.files || []);
|
||||||
e.target.value = ''; // reset for next upload
|
e.target.value = ''; // reset for next upload
|
||||||
await _uploadComposeFiles(files);
|
await _uploadComposeFiles(files);
|
||||||
}
|
}
|
||||||
@@ -5928,7 +5963,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function _handleMarkdownImageUpload(e) {
|
async function _handleMarkdownImageUpload(e) {
|
||||||
const files = e.target.files;
|
const files = Array.from(e.target.files || []);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
await _uploadMarkdownImages(files);
|
await _uploadMarkdownImages(files);
|
||||||
}
|
}
|
||||||
@@ -15239,7 +15274,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
menu.style.top = (rect.bottom + 2) + 'px';
|
menu.style.top = (rect.bottom + 2) + 'px';
|
||||||
menu.style.right = (window.innerWidth - rect.right) + 'px';
|
menu.style.right = (window.innerWidth - rect.right) + 'px';
|
||||||
menu.style.left = 'auto';
|
menu.style.left = 'auto';
|
||||||
menu.style.zIndex = '9999';
|
const editorPane = document.querySelector('.doc-editor-pane');
|
||||||
|
menu.style.zIndex = String(Math.max(topPortalZ(), (parseInt(getComputedStyle(editorPane).zIndex, 10) || 0) + 1));
|
||||||
|
|
||||||
const langLabel = lang ? lang.toUpperCase() : 'TXT';
|
const langLabel = lang ? lang.toUpperCase() : 'TXT';
|
||||||
// Form-backed markdown doc → primary export is the filled PDF, not the
|
// Form-backed markdown doc → primary export is the filled PDF, not the
|
||||||
@@ -15314,7 +15350,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
}
|
}
|
||||||
// Outside-click AND Escape both route through the central esc-stack via
|
// Outside-click AND Escape both route through the central esc-stack via
|
||||||
// bindMenuDismiss; onClose owns the actual node removal.
|
// bindMenuDismiss; onClose owns the actual node removal.
|
||||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
const exportBtn = document.getElementById('doc-footer-export-btn');
|
||||||
|
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target) && !exportBtn?.contains(ev.target));
|
||||||
}
|
}
|
||||||
|
|
||||||
function _richTextExportCss() {
|
function _richTextExportCss() {
|
||||||
|
|||||||
+154
-111
@@ -82,10 +82,9 @@ export function resolveQuickEditAgentModel() {
|
|||||||
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
|
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick Edit agent (planner) model options. Kept small + explicit; the local
|
// Quick Edit agent (planner) model options. Kept small + explicit;
|
||||||
// Qwen3.5-9B VL is the default. `value` encodes "<endpoint>::<model>".
|
// dynamically populated from /api/editor/agent-models.
|
||||||
export const QUICK_EDIT_AGENT_OPTIONS = [
|
export const QUICK_EDIT_AGENT_OPTIONS = [
|
||||||
{ value: 'http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B', label: 'Qwen3.5-9B VL · local' },
|
|
||||||
{ value: 'default', label: 'Server default (hosted planner)' },
|
{ value: 'default', label: 'Server default (hosted planner)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -141,12 +140,41 @@ export function modelCaps(modelId, endpointName, endpointType) {
|
|||||||
return { gen: false, inpaint: false };
|
return { gen: false, inpaint: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) {
|
// Global listeners resolve live controls instead of retaining editor instances.
|
||||||
|
function syncCommandModel(value) {
|
||||||
|
const select = document.getElementById('ge-ai-command-model');
|
||||||
|
if (!select || !select._modelsReady) return;
|
||||||
|
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
|
||||||
|
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
|
||||||
|
value = '';
|
||||||
|
}
|
||||||
|
if (select.value !== value) {
|
||||||
|
select.value = value;
|
||||||
|
if (select.value !== value) select.value = '';
|
||||||
|
updateFooterEditModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
|
||||||
|
window.addEventListener('storage', e => {
|
||||||
|
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
|
||||||
|
});
|
||||||
|
window.addEventListener('ge:model-endpoints-updated', e => {
|
||||||
|
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
export function wireAIModelSelectors({ container, apiBase = '', openCookbookForImg2img = () => {} } = {}) {
|
||||||
|
const root = container || document;
|
||||||
|
root._aiModelListeners?.abort();
|
||||||
|
const listeners = new AbortController();
|
||||||
|
root._aiModelListeners = listeners;
|
||||||
|
const listen = (target, type, handler, options = {}) => target?.addEventListener(type, handler, {
|
||||||
|
...(typeof options === 'boolean' ? { capture: options } : options), signal: listeners.signal,
|
||||||
|
});
|
||||||
// Delegated handler for the "+ Serve a model in Cookbook…" sentinel
|
// Delegated handler for the "+ Serve a model in Cookbook…" sentinel
|
||||||
// option — catches clicks regardless of whether loadAIModels has
|
// option — catches clicks regardless of whether loadAIModels has
|
||||||
// rewired the individual select yet and survives any innerHTML
|
// rewired the individual select yet and survives any innerHTML
|
||||||
// reset later.
|
// reset later.
|
||||||
container.addEventListener('change', (e) => {
|
listen(root, 'change', (e) => {
|
||||||
const sel = e.target.closest('select');
|
const sel = e.target.closest('select');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
if (sel.value !== '__serve_cookbook__') return;
|
if (sel.value !== '__serve_cookbook__') return;
|
||||||
@@ -156,7 +184,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
openCookbookForImg2img();
|
openCookbookForImg2img();
|
||||||
});
|
});
|
||||||
// Track prior value so we can restore it after the sentinel fires.
|
// Track prior value so we can restore it after the sentinel fires.
|
||||||
container.addEventListener('focus', (e) => {
|
listen(root, 'focus', (e) => {
|
||||||
const sel = e.target.closest('select');
|
const sel = e.target.closest('select');
|
||||||
if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value;
|
if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value;
|
||||||
}, true);
|
}, true);
|
||||||
@@ -165,14 +193,31 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
|
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
|
||||||
const aiCommandSelect = document.getElementById('ge-ai-command-model');
|
const aiCommandSelect = document.getElementById('ge-ai-command-model');
|
||||||
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
|
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
|
||||||
if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
|
if (aiInpaintSelect) listen(aiInpaintSelect, 'change', () => updateFooterEditModel());
|
||||||
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
|
// The Quick Edit agent (planner) picker: dynamically populates from
|
||||||
// The Quick Edit agent (planner) picker is static for now (local VL default,
|
// /api/editor/agent-models; restore/save choice from localStorage.
|
||||||
// server default option); restore/save its choice from localStorage.
|
const populateAgentSelect = async () => {
|
||||||
const populateAgentSelect = () => {
|
|
||||||
if (!aiCommandAgentSelect) return;
|
if (!aiCommandAgentSelect) return;
|
||||||
|
const prevVal = aiCommandAgentSelect.value;
|
||||||
|
let agentOptions = [...QUICK_EDIT_AGENT_OPTIONS];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/api/editor/agent-models`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (listeners.signal.aborted || !root.isConnected) return;
|
||||||
|
const serverModels = data.models || [];
|
||||||
|
for (const m of serverModels) {
|
||||||
|
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : '')}::${m.id}`;
|
||||||
|
const label = m.label || m.id;
|
||||||
|
if (!agentOptions.some(o => o.value === optVal)) {
|
||||||
|
agentOptions.push({ value: optVal, label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
aiCommandAgentSelect.innerHTML = '';
|
aiCommandAgentSelect.innerHTML = '';
|
||||||
for (const opt of QUICK_EDIT_AGENT_OPTIONS) {
|
for (const opt of agentOptions) {
|
||||||
const el = document.createElement('option');
|
const el = document.createElement('option');
|
||||||
el.value = opt.value;
|
el.value = opt.value;
|
||||||
el.textContent = opt.label;
|
el.textContent = opt.label;
|
||||||
@@ -182,11 +227,13 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
const saved = localStorage.getItem('ge-ai-command-agent-model');
|
const saved = localStorage.getItem('ge-ai-command-agent-model');
|
||||||
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
|
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
|
||||||
aiCommandAgentSelect.value = saved;
|
aiCommandAgentSelect.value = saved;
|
||||||
|
} else if (prevVal && [...aiCommandAgentSelect.options].some(o => o.value === prevVal)) {
|
||||||
|
aiCommandAgentSelect.value = prevVal;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
if (!aiCommandAgentSelect._agentWired) {
|
if (aiCommandAgentSelect._agentWired !== listeners) {
|
||||||
aiCommandAgentSelect._agentWired = true;
|
aiCommandAgentSelect._agentWired = listeners;
|
||||||
aiCommandAgentSelect.addEventListener('change', () => {
|
listen(aiCommandAgentSelect, 'change', () => {
|
||||||
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
|
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -196,16 +243,19 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
// select nor the inpaint select nor any per-tool select).
|
// select nor the inpaint select nor any per-tool select).
|
||||||
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
|
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
|
||||||
!document.querySelector('select.ge-tool-model')) return;
|
!document.querySelector('select.ge-tool-model')) return;
|
||||||
populateAgentSelect();
|
void populateAgentSelect();
|
||||||
|
|
||||||
async function loadAIModels(opts = {}) {
|
async function loadAIModels(opts = {}) {
|
||||||
try {
|
try {
|
||||||
const selectBaseUrl = opts.selectBaseUrl || '';
|
const selectBaseUrl = opts.selectBaseUrl || '';
|
||||||
const prevGenValue = aiGenSelect?.value || '';
|
const prevGenValue = aiGenSelect?.value || '';
|
||||||
const prevInpaintValue = aiInpaintSelect?.value || '';
|
const prevInpaintValue = aiInpaintSelect?.value || '';
|
||||||
const res = await fetch(`${apiBase}/api/model-endpoints`);
|
const res = await fetch(`${apiBase}/api/editor/image-models`);
|
||||||
|
if (listeners.signal.aborted || !root.isConnected) return;
|
||||||
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
|
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
|
||||||
const endpoints = await res.json();
|
const data = await res.json();
|
||||||
|
if (listeners.signal.aborted || !root.isConnected) return;
|
||||||
|
const modelsList = data.models || [];
|
||||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
||||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||||
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
|
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
|
||||||
@@ -215,59 +265,52 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
let selectedGen = null;
|
let selectedGen = null;
|
||||||
let selectedInpaint = null;
|
let selectedInpaint = null;
|
||||||
const loaded = [];
|
const loaded = [];
|
||||||
for (const ep of endpoints) {
|
for (const m of modelsList) {
|
||||||
if (!ep.is_enabled) continue;
|
const modelId = m.id || '';
|
||||||
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
|
const caps = modelCaps(modelId, m.provider, m.kind);
|
||||||
const models = hasListedModels ? sortModelIds(ep.models) : [''];
|
const canGen = Boolean(caps.gen || m.kind === 'generate');
|
||||||
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image';
|
const canInpaint = Boolean(caps.inpaint || m.kind === 'edit');
|
||||||
// Image/inpaint endpoints can be called by URL even when their
|
if (!canGen && !canInpaint) continue;
|
||||||
// /models cache is still empty, so don't strand a freshly served
|
|
||||||
// Cookbook model as "(offline)" in the editor picker.
|
// Use opaque endpoint_id if available; never fall back to raw base_url
|
||||||
const epUsable = !!ep.online || isImageEndpoint;
|
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : '');
|
||||||
for (const modelId of models) {
|
const value = endpointKey ? `${endpointKey}::${modelId}` : modelId;
|
||||||
const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type);
|
const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider);
|
||||||
if (!caps.gen && !caps.inpaint) continue;
|
const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : '';
|
||||||
// Encode "<base_url>::<model_id>" so the value carries both pieces.
|
const label = `${shortModel}${epHint}`;
|
||||||
const value = `${ep.base_url}::${modelId}`;
|
const epUsable = true;
|
||||||
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url);
|
loaded.push({ value, label, gen: canGen, inpaint: canInpaint, usable: epUsable, endpoint: endpointKey, model: modelId });
|
||||||
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : '';
|
if (canGen && aiGenSelect) {
|
||||||
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`;
|
const opt = document.createElement('option');
|
||||||
loaded.push({ value, label, gen: caps.gen, inpaint: caps.inpaint, usable: epUsable, endpoint: ep.base_url, model: modelId });
|
opt.value = value;
|
||||||
if (caps.gen && aiGenSelect) {
|
opt.textContent = label;
|
||||||
|
opt.disabled = !epUsable;
|
||||||
|
aiGenSelect.appendChild(opt);
|
||||||
|
if (epUsable && !firstGen) firstGen = value;
|
||||||
|
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedGen) selectedGen = value;
|
||||||
|
}
|
||||||
|
if (canInpaint && aiInpaintSelect) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = value;
|
||||||
|
opt.textContent = label;
|
||||||
|
opt.disabled = !epUsable;
|
||||||
|
aiInpaintSelect.appendChild(opt);
|
||||||
|
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedInpaint) selectedInpaint = value;
|
||||||
|
}
|
||||||
|
if (canInpaint || canGen) {
|
||||||
|
for (const ts of perToolSelects) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = value;
|
opt.value = value;
|
||||||
opt.textContent = label;
|
opt.textContent = label;
|
||||||
opt.disabled = !epUsable;
|
opt.disabled = !epUsable;
|
||||||
aiGenSelect.appendChild(opt);
|
ts.appendChild(opt);
|
||||||
if (epUsable && !firstGen) firstGen = value;
|
|
||||||
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value;
|
|
||||||
}
|
}
|
||||||
if (caps.inpaint && aiInpaintSelect) {
|
if (aiCommandSelect) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = value;
|
opt.value = value;
|
||||||
opt.textContent = label;
|
opt.textContent = label;
|
||||||
opt.disabled = !epUsable;
|
opt.disabled = !epUsable;
|
||||||
aiInpaintSelect.appendChild(opt);
|
aiCommandSelect.appendChild(opt);
|
||||||
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
|
|
||||||
}
|
|
||||||
// Per-tool selectors get every img2img-capable entry. Both
|
|
||||||
// caps.inpaint AND caps.gen models work for harmonize /
|
|
||||||
// style / upscale (anything that can do img2img).
|
|
||||||
if (caps.inpaint || caps.gen) {
|
|
||||||
for (const ts of perToolSelects) {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = value;
|
|
||||||
opt.textContent = label;
|
|
||||||
opt.disabled = !epUsable;
|
|
||||||
ts.appendChild(opt);
|
|
||||||
}
|
|
||||||
if (aiCommandSelect) {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = value;
|
|
||||||
opt.textContent = label;
|
|
||||||
opt.disabled = !epUsable;
|
|
||||||
aiCommandSelect.appendChild(opt);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,32 +343,50 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
if (aiGenSelect) appendServeSentinel(aiGenSelect);
|
if (aiGenSelect) appendServeSentinel(aiGenSelect);
|
||||||
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
|
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
|
||||||
if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
|
if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
|
||||||
// Wire the sentinel on the Gen + Inpaint selects too.
|
// Sentinel changes are handled by the component's delegated listener.
|
||||||
const wireServeSentinel = (sel) => {
|
|
||||||
if (!sel) return;
|
|
||||||
let prev = sel.value;
|
|
||||||
sel.addEventListener('change', () => {
|
|
||||||
if (sel.value === '__serve_cookbook__') {
|
|
||||||
sel.value = prev;
|
|
||||||
openCookbookForImg2img();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
prev = sel.value;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
wireServeSentinel(aiGenSelect);
|
|
||||||
wireServeSentinel(aiInpaintSelect);
|
|
||||||
wireServeSentinel(aiCommandSelect);
|
|
||||||
if (aiCommandSelect) {
|
if (aiCommandSelect) {
|
||||||
|
aiCommandSelect._modelsReady = true;
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('ge-ai-command-model');
|
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
|
||||||
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) aiCommandSelect.value = saved;
|
let saved = rawSaved;
|
||||||
|
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
|
||||||
|
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
|
||||||
|
const matching = loaded.filter(l => l.model === modelPart);
|
||||||
|
if (matching.length === 1) {
|
||||||
|
saved = matching[0].value;
|
||||||
|
} else {
|
||||||
|
saved = '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (saved) localStorage.setItem('ge-ai-command-model', saved);
|
||||||
|
else localStorage.removeItem('ge-ai-command-model');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
|
||||||
|
aiCommandSelect.value = saved;
|
||||||
|
} else {
|
||||||
|
aiCommandSelect.value = '';
|
||||||
|
}
|
||||||
|
if (aiCommandSelect.value !== rawSaved) {
|
||||||
|
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
||||||
|
else localStorage.removeItem('ge-ai-command-model');
|
||||||
|
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
aiCommandSelect.addEventListener('change', () => {
|
|
||||||
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
if (aiCommandSelect._commandWired !== listeners) {
|
||||||
try { localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); } catch {}
|
aiCommandSelect._commandWired = listeners;
|
||||||
updateFooterEditModel();
|
listen(aiCommandSelect, 'change', () => {
|
||||||
});
|
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
||||||
|
const val = aiCommandSelect.value || '';
|
||||||
|
try {
|
||||||
|
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
||||||
|
else localStorage.removeItem('ge-ai-command-model');
|
||||||
|
} catch {}
|
||||||
|
updateFooterEditModel();
|
||||||
|
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Restore each per-tool selection from localStorage.
|
// Restore each per-tool selection from localStorage.
|
||||||
for (const ts of perToolSelects) {
|
for (const ts of perToolSelects) {
|
||||||
@@ -337,7 +398,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
let prevValue = ts.value;
|
let prevValue = ts.value;
|
||||||
ts.addEventListener('change', () => {
|
listen(ts, 'change', () => {
|
||||||
if (ts.value === '__serve_cookbook__') {
|
if (ts.value === '__serve_cookbook__') {
|
||||||
ts.value = prevValue;
|
ts.value = prevValue;
|
||||||
openCookbookForImg2img();
|
openCookbookForImg2img();
|
||||||
@@ -350,6 +411,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
|
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
|
||||||
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (listeners.signal.aborted || !root.isConnected) return;
|
||||||
// Fetch failed — still give the user the affordance to set up
|
// Fetch failed — still give the user the affordance to set up
|
||||||
// a model. Otherwise the dropdown shows only "Auto" with no
|
// a model. Otherwise the dropdown shows only "Auto" with no
|
||||||
// hint about what to do next.
|
// hint about what to do next.
|
||||||
@@ -358,37 +420,18 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
|
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
|
||||||
if (aiCommandSelect) aiCommandSelect.innerHTML = fallback;
|
if (aiCommandSelect) aiCommandSelect.innerHTML = fallback;
|
||||||
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
|
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
|
||||||
const wireServe = (sel) => {
|
void populateAgentSelect();
|
||||||
if (!sel) return;
|
|
||||||
let prev = sel.value;
|
|
||||||
sel.addEventListener('change', () => {
|
|
||||||
if (sel.value === '__serve_cookbook__') {
|
|
||||||
sel.value = prev;
|
|
||||||
openCookbookForImg2img();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
prev = sel.value;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
wireServe(aiGenSelect);
|
|
||||||
wireServe(aiInpaintSelect);
|
|
||||||
wireServe(aiCommandSelect);
|
|
||||||
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
|
|
||||||
populateAgentSelect();
|
|
||||||
updateFooterEditModel();
|
updateFooterEditModel();
|
||||||
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
|
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
|
||||||
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadAIModels();
|
loadAIModels();
|
||||||
const onModelEndpointsUpdated = (e) => {
|
if (aiCommandSelect) aiCommandSelect._refreshAIModels = baseUrl => {
|
||||||
if (!container.isConnected) {
|
if (listeners.signal.aborted || !root.isConnected) return;
|
||||||
window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
|
void loadAIModels({ selectBaseUrl: baseUrl });
|
||||||
return;
|
void populateAgentSelect();
|
||||||
}
|
|
||||||
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
|
|
||||||
};
|
};
|
||||||
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
|
|
||||||
// Re-fetch the model list when the user opens the inpaint dropdown,
|
// Re-fetch the model list when the user opens the inpaint dropdown,
|
||||||
// so a model served via Cookbook mid-edit shows up without having to
|
// so a model served via Cookbook mid-edit shows up without having to
|
||||||
// close and reopen the editor. Debounced to one refresh per 3s so
|
// close and reopen the editor. Debounced to one refresh per 3s so
|
||||||
@@ -407,5 +450,5 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
if ([...sel.options].some(o => o.value === keep)) sel.value = keep;
|
if ([...sel.options].some(o => o.value === keep)) sel.value = keep;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
container.addEventListener('mousedown', refreshOnOpen, true);
|
listen(root, 'mousedown', refreshOnOpen, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
|||||||
// entries in a compact `<select>` overlay reachable from a "Panels"
|
// entries in a compact `<select>` overlay reachable from a "Panels"
|
||||||
// button in the layers header, without touching the peek/expand model.
|
// button in the layers header, without touching the peek/expand model.
|
||||||
let mobilePanels = null;
|
let mobilePanels = null;
|
||||||
if (!useDock && Array.isArray(extraPanels) && extraPanels.length) {
|
if (Array.isArray(extraPanels) && extraPanels.length) {
|
||||||
const mDock = buildDock({
|
const mDock = buildDock({
|
||||||
entries: extraPanels,
|
entries: extraPanels,
|
||||||
persistKey: 'ge-dock-active',
|
persistKey: 'ge-dock-active',
|
||||||
@@ -218,7 +218,9 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
|||||||
overlay.hidden = !overlay.hidden;
|
overlay.hidden = !overlay.hidden;
|
||||||
});
|
});
|
||||||
hdr.appendChild(panelsBtn);
|
hdr.appendChild(panelsBtn);
|
||||||
|
if (useDock) panelsBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
if (useDock) overlay.style.display = 'none';
|
||||||
mobilePanels = overlay;
|
mobilePanels = overlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +228,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
|||||||
// The panels are only re-parented, so every existing selector and
|
// The panels are only re-parented, so every existing selector and
|
||||||
// listener keeps working; switching tabs just toggles `hidden`.
|
// listener keeps working; switching tabs just toggles `hidden`.
|
||||||
let dock = null;
|
let dock = null;
|
||||||
if (useDock) { rightPanel.classList.add('ge-right-panel--dock');
|
if (Array.isArray(extraPanels) && extraPanels.length) {
|
||||||
dock = buildDock({
|
dock = buildDock({
|
||||||
entries: [
|
entries: [
|
||||||
...(Array.isArray(extraPanels) ? extraPanels : []),
|
...(Array.isArray(extraPanels) ? extraPanels : []),
|
||||||
@@ -239,11 +241,12 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
|||||||
onCloseGroup,
|
onCloseGroup,
|
||||||
});
|
});
|
||||||
rightPanel.appendChild(dock.dock);
|
rightPanel.appendChild(dock.dock);
|
||||||
// Layers stays pinned below the dock so the layer list is always
|
if (!useDock) dock.dock.style.display = 'none';
|
||||||
// visible while editing (Photoshop-style), regardless of the active
|
|
||||||
// dock tab. Only the tool options + studio panels live in tabs.
|
|
||||||
rightPanel.appendChild(layerPanel);
|
|
||||||
}
|
}
|
||||||
|
if (useDock) {
|
||||||
|
rightPanel.classList.add('ge-right-panel--dock');
|
||||||
|
}
|
||||||
|
rightPanel.appendChild(layerPanel);
|
||||||
|
|
||||||
// Horizontal drag handle on the LEFT edge of the right panel — drag
|
// Horizontal drag handle on the LEFT edge of the right panel — drag
|
||||||
// left to widen, right to narrow. Persists chosen width in
|
// left to widen, right to narrow. Persists chosen width in
|
||||||
@@ -286,5 +289,5 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
|||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { rightPanel, controls, layerPanel, panelResize, dock };
|
return { rightPanel, controls, layerPanel, panelResize, dock, mobilePanels };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
|||||||
|
|
||||||
const closeAllFlyouts = () => {
|
const closeAllFlyouts = () => {
|
||||||
toolbar.querySelectorAll('.ge-tool-slot.open').forEach(s => s.classList.remove('open'));
|
toolbar.querySelectorAll('.ge-tool-slot.open').forEach(s => s.classList.remove('open'));
|
||||||
|
toolbar.classList.remove('ge-flyout-open');
|
||||||
|
toolbar.closest('.ge-toolbar-wrap')?.classList.remove('ge-flyout-open');
|
||||||
};
|
};
|
||||||
|
|
||||||
const openFlyout = (slotEl, face) => {
|
const openFlyout = (slotEl, face) => {
|
||||||
@@ -116,6 +118,8 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
|||||||
closeAllFlyouts();
|
closeAllFlyouts();
|
||||||
if (isOpen) return;
|
if (isOpen) return;
|
||||||
slotEl.classList.add('open');
|
slotEl.classList.add('open');
|
||||||
|
toolbar.classList.add('ge-flyout-open');
|
||||||
|
toolbar.closest('.ge-toolbar-wrap')?.classList.add('ge-flyout-open');
|
||||||
const flyout = slotEl.querySelector('.ge-tool-flyout');
|
const flyout = slotEl.querySelector('.ge-tool-flyout');
|
||||||
if (!flyout) return;
|
if (!flyout) return;
|
||||||
const r = face.getBoundingClientRect();
|
const r = face.getBoundingClientRect();
|
||||||
@@ -242,7 +246,13 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
|||||||
: soft >= 70
|
: soft >= 70
|
||||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>'
|
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>'
|
||||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>';
|
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>';
|
||||||
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>`);
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'ge-tool-icon';
|
||||||
|
icon.innerHTML = presetIcon;
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'ge-tool-flyout-label';
|
||||||
|
label.textContent = preset.name;
|
||||||
|
presetItem.append(icon, label);
|
||||||
presetItem.addEventListener('click', (ev) => {
|
presetItem.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
setInner(face, 'brush');
|
setInner(face, 'brush');
|
||||||
|
|||||||
@@ -535,6 +535,7 @@ export function buildTopbar() {
|
|||||||
<span class="ge-stacked-glyph">
|
<span class="ge-stacked-glyph">
|
||||||
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
|
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
|
||||||
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
<svg hidden class="ge-save-state-icon ge-save-state-error" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="ge-stacked-label ge-save-action-label">SAVE</span>
|
<span class="ge-stacked-label ge-save-action-label">SAVE</span>
|
||||||
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
|
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
|
||||||
|
|||||||
@@ -8,8 +8,29 @@
|
|||||||
* @param {HTMLCanvasElement} canvas
|
* @param {HTMLCanvasElement} canvas
|
||||||
* @returns {{x: number, y: number}}
|
* @returns {{x: number, y: number}}
|
||||||
*/
|
*/
|
||||||
|
import { state } from './state.js';
|
||||||
|
|
||||||
|
let _cachedCanvas = null;
|
||||||
|
let _cachedRect = null;
|
||||||
|
let _cachedRectTime = 0;
|
||||||
|
|
||||||
|
export function invalidateCanvasCoordsCache() {
|
||||||
|
_cachedCanvas = null;
|
||||||
|
_cachedRect = null;
|
||||||
|
_cachedRectTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function canvasCoords(e, canvas) {
|
export function canvasCoords(e, canvas) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const now = performance.now();
|
||||||
|
let rect;
|
||||||
|
if (_cachedCanvas === canvas && _cachedRect && (state.drawing || (now - _cachedRectTime < 64))) {
|
||||||
|
rect = _cachedRect;
|
||||||
|
} else {
|
||||||
|
rect = canvas.getBoundingClientRect();
|
||||||
|
_cachedCanvas = canvas;
|
||||||
|
_cachedRect = rect;
|
||||||
|
_cachedRectTime = now;
|
||||||
|
}
|
||||||
const scaleX = canvas.width / rect.width;
|
const scaleX = canvas.width / rect.width;
|
||||||
const scaleY = canvas.height / rect.height;
|
const scaleY = canvas.height / rect.height;
|
||||||
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
|
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
* }} ctx
|
* }} ctx
|
||||||
*/
|
*/
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
|
import { invalidateCanvasCoordsCache } from './canvas-coords.js';
|
||||||
import {
|
import {
|
||||||
applyCanvasPan,
|
applyCanvasPan,
|
||||||
isDirectPanIntent,
|
isDirectPanIntent,
|
||||||
@@ -58,6 +59,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
canvasWindowBindings?.abort();
|
canvasWindowBindings?.abort();
|
||||||
canvasWindowBindings = new AbortController();
|
canvasWindowBindings = new AbortController();
|
||||||
const { signal } = canvasWindowBindings;
|
const { signal } = canvasWindowBindings;
|
||||||
|
|
||||||
|
// Invalidate coordinate cache on layout, scroll, resize or orientation shifts
|
||||||
|
window.addEventListener('resize', invalidateCanvasCoordsCache, { signal, passive: true });
|
||||||
|
window.addEventListener('scroll', invalidateCanvasCoordsCache, { signal, passive: true });
|
||||||
|
window.addEventListener('orientationchange', invalidateCanvasCoordsCache, { signal, passive: true });
|
||||||
|
canvasArea.addEventListener('scroll', invalidateCanvasCoordsCache, { passive: true });
|
||||||
let suppressMouseUntil = 0;
|
let suppressMouseUntil = 0;
|
||||||
// Tool-aware right-click menu on the image and the surrounding area.
|
// Tool-aware right-click menu on the image and the surrounding area.
|
||||||
wireCanvasContextMenu(canvasArea, { signal });
|
wireCanvasContextMenu(canvasArea, { signal });
|
||||||
@@ -90,7 +97,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
window.addEventListener('mousemove', (e) => {
|
window.addEventListener('mousemove', (e) => {
|
||||||
if (Date.now() < suppressMouseUntil) return;
|
if (Date.now() < suppressMouseUntil) return;
|
||||||
// Only shield the raw-coordinate selection/crop rects from chrome.
|
// Only shield the raw-coordinate selection/crop rects from chrome.
|
||||||
if (isUiChrome(e) && directGestureActive()) return;
|
if (directGestureActive() && isUiChrome(e)) return;
|
||||||
continueDraw(e);
|
continueDraw(e);
|
||||||
}, { signal });
|
}, { signal });
|
||||||
window.addEventListener('mouseup', (e) => {
|
window.addEventListener('mouseup', (e) => {
|
||||||
@@ -240,11 +247,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio));
|
const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio));
|
||||||
if (Math.abs(newZoom - state.zoom) > 0.001) {
|
if (Math.abs(newZoom - state.zoom) > 0.001) {
|
||||||
state.zoom = newZoom;
|
state.zoom = newZoom;
|
||||||
|
// A pinch is an explicit zoom; layout refits must not undo it.
|
||||||
|
state.zoomViewMode = 'scale';
|
||||||
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
|
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
|
||||||
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
|
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
|
||||||
const label = state.container.querySelector('.ge-zoom-label');
|
const label = state.container.querySelector('.ge-zoom-label');
|
||||||
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
|
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
|
||||||
syncZoomControls?.();
|
syncZoomControls?.();
|
||||||
|
invalidateCanvasCoordsCache();
|
||||||
onViewportChange?.();
|
onViewportChange?.();
|
||||||
}
|
}
|
||||||
const dx = info.cx - multiStartCenter.x;
|
const dx = info.cx - multiStartCenter.x;
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ export function wireClipboardAndDrop({
|
|||||||
// inside the gallery modal's role="dialog" modal-content, so that would
|
// inside the gallery modal's role="dialog" modal-content, so that would
|
||||||
// swallow every paste (including the internal Clipboard + Ctrl+V).
|
// swallow every paste (including the internal Clipboard + Ctrl+V).
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
const insideEditor = target && container && container.contains(target);
|
// A paste dispatched at window has a non-Node target; contains() would throw.
|
||||||
|
const insideEditor = target instanceof Node && !!container && container.contains(target);
|
||||||
if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
|
if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
|
||||||
|
|
||||||
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
|
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import {
|
|||||||
import { drawHistogram } from './histogram.js';
|
import { drawHistogram } from './histogram.js';
|
||||||
import { parseCubeLut, generateCubeLut } from './cube-lut.js';
|
import { parseCubeLut, generateCubeLut } from './cube-lut.js';
|
||||||
|
|
||||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
|
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null, schedulePersist = null }) {
|
||||||
function suppressLayerGhostTap() {
|
function suppressLayerGhostTap() {
|
||||||
window.__geSuppressLayerTapUntil = Date.now() + 650;
|
window.__geSuppressLayerTapUntil = Date.now() + 650;
|
||||||
}
|
}
|
||||||
@@ -511,6 +511,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
composite();
|
composite();
|
||||||
renderLayerPanel();
|
renderLayerPanel();
|
||||||
closeAdjPopup();
|
closeAdjPopup();
|
||||||
|
schedulePersist?.();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,8 +536,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
|
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
|
||||||
return p.channels[channel];
|
return p.channels[channel];
|
||||||
};
|
};
|
||||||
|
// Params come from project files and drafts, which only validate the
|
||||||
|
// adjustment type; coerce anything interpolated into markup below.
|
||||||
|
const finite = value => (Number.isFinite(Number(value)) ? Number(value) : 0);
|
||||||
|
const hexColor = (value, fallback) => (/^#[0-9a-f]{6}$/i.test(value) ? value : fallback);
|
||||||
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
||||||
const sliderRow = (key, label, min, max, value, suffix, defaultValue) => `
|
const sliderRow = (key, label, min, max, rawValue, suffix, defaultValue) => {
|
||||||
|
const value = finite(rawValue);
|
||||||
|
return `
|
||||||
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
|
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
|
||||||
<label>${label}</label>
|
<label>${label}</label>
|
||||||
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
|
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
|
||||||
@@ -544,6 +551,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
|
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
};
|
||||||
if (type === 'brightness-contrast') {
|
if (type === 'brightness-contrast') {
|
||||||
const bSlider = Math.round((p.brightness - 1) * 100);
|
const bSlider = Math.round((p.brightness - 1) * 100);
|
||||||
const cSlider = Math.round((p.contrast - 1) * 100);
|
const cSlider = Math.round((p.contrast - 1) * 100);
|
||||||
@@ -692,7 +700,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
});
|
});
|
||||||
} else if (type === 'color-balance') {
|
} else if (type === 'color-balance') {
|
||||||
// Color-tinted slider ends so the user sees what direction does what.
|
// Color-tinted slider ends so the user sees what direction does what.
|
||||||
const cbRow = (key, leftCol, rightCol, label, value) => `
|
const cbRow = (key, leftCol, rightCol, label, rawValue, value = finite(rawValue)) => `
|
||||||
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
|
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
|
||||||
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
|
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
|
||||||
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
|
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
|
||||||
@@ -749,6 +757,9 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
buildAdjBody(layer, type, body, popEl);
|
buildAdjBody(layer, type, body, popEl);
|
||||||
});
|
});
|
||||||
} else if (type === 'gradient-map') {
|
} else if (type === 'gradient-map') {
|
||||||
|
const gradientDefaults = defaultAdjParams('gradient-map');
|
||||||
|
p.shadows = hexColor(p.shadows, gradientDefaults.shadows);
|
||||||
|
p.highlights = hexColor(p.highlights, gradientDefaults.highlights);
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
|
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
|
||||||
<div class="ge-gradient-color-row">
|
<div class="ge-gradient-color-row">
|
||||||
@@ -789,7 +800,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="ge-adj-row">
|
<div class="ge-adj-row">
|
||||||
<label>Color</label>
|
<label>Color</label>
|
||||||
<input type="color" data-photo-key="color" value="${p.color || '#ec8a00'}">
|
<input type="color" data-photo-key="color" value="${hexColor(p.color, '#ec8a00')}">
|
||||||
</div>
|
</div>
|
||||||
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
|
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
|
||||||
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
|
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
|
||||||
@@ -838,7 +849,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' };
|
const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' };
|
||||||
const strength = p.strength == null ? 100 : p.strength;
|
const strength = p.strength == null ? 100 : p.strength;
|
||||||
const hasCube = !!(p.cube && p.cube.size && p.cube.data);
|
const hasCube = !!(p.cube && p.cube.size && p.cube.data);
|
||||||
const lutName = hasCube ? `Loaded LUT · ${p.cube.size}³` : 'No .cube loaded';
|
const lutName = hasCube ? `Loaded LUT · ${finite(p.cube.size)}³` : 'No .cube loaded';
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<label class="ge-adj-channel-field">Lookup preset
|
<label class="ge-adj-channel-field">Lookup preset
|
||||||
<select class="ge-adj-lut-select">
|
<select class="ge-adj-lut-select">
|
||||||
@@ -972,6 +983,8 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
p.strength = raw; display = raw + '%';
|
p.strength = raw; display = raw + '%';
|
||||||
} else if (type === 'white-balance') {
|
} else if (type === 'white-balance') {
|
||||||
p[key] = raw;
|
p[key] = raw;
|
||||||
|
} else if (type === 'vibrance') {
|
||||||
|
p[key] = raw;
|
||||||
} else if (type === 'black-white') {
|
} else if (type === 'black-white') {
|
||||||
p[key] = raw;
|
p[key] = raw;
|
||||||
} else if (type === 'shadows-highlights') {
|
} else if (type === 'shadows-highlights') {
|
||||||
|
|||||||
@@ -1948,7 +1948,8 @@ export function createLayerPanelRenderer(deps) {
|
|||||||
});
|
});
|
||||||
const sName = document.createElement('span');
|
const sName = document.createElement('span');
|
||||||
sName.className = 'ge-layer-name ge-adj-sub-name';
|
sName.className = 'ge-layer-name ge-adj-sub-name';
|
||||||
sName.innerHTML = `<span class="ge-adj-sub-icon">◌</span><span>${effectLabel(effect.type)}</span>`;
|
sName.innerHTML = '<span class="ge-adj-sub-icon">◌</span><span></span>';
|
||||||
|
sName.lastElementChild.textContent = effectLabel(effect.type);
|
||||||
sName.title = 'Edit effect (drag onto another layer to copy)';
|
sName.title = 'Edit effect (drag onto another layer to copy)';
|
||||||
sName.addEventListener('click', event => {
|
sName.addEventListener('click', event => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ const GRADIENTS = [
|
|||||||
['Spectrum', ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']],
|
['Spectrum', ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']],
|
||||||
];
|
];
|
||||||
|
|
||||||
function el(tag, className, html) {
|
function el(tag, className, text) {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
if (className) node.className = className;
|
if (className) node.className = className;
|
||||||
if (html != null) node.innerHTML = html;
|
if (text != null) node.textContent = text;
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -274,7 +274,11 @@ export function createStudioPanels({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
shapeLayers.slice().reverse().forEach(layer => {
|
shapeLayers.slice().reverse().forEach(layer => {
|
||||||
const button = el('button', 'ge-channel-row', `<span class="ge-channel-name">${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}</span>`);
|
const button = el('button', 'ge-channel-row');
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'ge-channel-name';
|
||||||
|
name.textContent = `${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}`;
|
||||||
|
button.appendChild(name);
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.addEventListener('click', () => { onSelectLayer(layer.id); renderPaths(); });
|
button.addEventListener('click', () => { onSelectLayer(layer.id); renderPaths(); });
|
||||||
pathBody.appendChild(button);
|
pathBody.appendChild(button);
|
||||||
|
|||||||
@@ -400,10 +400,15 @@ export const state = {
|
|||||||
// Loading-overlay element (whirlpool + label).
|
// Loading-overlay element (whirlpool + label).
|
||||||
editorLoadingEl: null,
|
editorLoadingEl: null,
|
||||||
|
|
||||||
// ── Draft persistence ──
|
// ── Draft persistence & revision tracking ──
|
||||||
draftId: null,
|
draftId: null,
|
||||||
draftName: '',
|
draftName: '',
|
||||||
documentId: null,
|
documentId: null,
|
||||||
|
documentRevision: 0,
|
||||||
|
persistingRevision: 0,
|
||||||
|
savedRevision: 0,
|
||||||
|
persistRetryTimer: null,
|
||||||
|
persistRetryCount: 0,
|
||||||
persistTimer: null,
|
persistTimer: null,
|
||||||
// Current PUT/POST promise so concurrent saves can chain.
|
// Current PUT/POST promise so concurrent saves can chain.
|
||||||
persistInFlight: null,
|
persistInFlight: null,
|
||||||
|
|||||||
@@ -12,15 +12,21 @@ function toolSettings(tool) {
|
|||||||
return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness };
|
return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _lastColor = null;
|
||||||
|
let _lastTransparentColor = null;
|
||||||
function transparentColor(color) {
|
function transparentColor(color) {
|
||||||
|
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor;
|
||||||
|
_lastColor = color;
|
||||||
if (color.startsWith('#')) {
|
if (color.startsWith('#')) {
|
||||||
const raw = color.slice(1);
|
const raw = color.slice(1);
|
||||||
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
|
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
|
||||||
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
|
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
|
||||||
return `rgba(${rgb.join(',')},0)`;
|
_lastTransparentColor = `rgba(${rgb.join(',')},0)`;
|
||||||
|
return _lastTransparentColor;
|
||||||
}
|
}
|
||||||
const match = color.match(/rgba?\(([^)]+)\)/);
|
const match = color.match(/rgba?\(([^)]+)\)/);
|
||||||
return match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
||||||
|
return _lastTransparentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
||||||
|
|||||||
@@ -10,6 +10,16 @@
|
|||||||
* the Window > Workspace submenu (wire-extra-menus.js).
|
* the Window > Workspace submenu (wire-extra-menus.js).
|
||||||
*/
|
*/
|
||||||
export function wireSubmenuToggles(menu) {
|
export function wireSubmenuToggles(menu) {
|
||||||
|
menu.addEventListener('mouseover', (e) => {
|
||||||
|
if (e.target.closest('.ge-menu-parent, .ge-submenu')) {
|
||||||
|
menu.classList.add('ge-flyout-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
menu.addEventListener('mouseleave', () => {
|
||||||
|
if (!menu.querySelector('.ge-menu-parent.open')) {
|
||||||
|
menu.classList.remove('ge-flyout-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
menu.addEventListener('click', (e) => {
|
menu.addEventListener('click', (e) => {
|
||||||
const parent = e.target.closest('.ge-menu-parent');
|
const parent = e.target.closest('.ge-menu-parent');
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
|
|||||||
@@ -241,11 +241,13 @@ export function createTransformSession({
|
|||||||
}
|
}
|
||||||
if (state.transformPopup) {
|
if (state.transformPopup) {
|
||||||
try {
|
try {
|
||||||
|
state.transformPopup.closest('.ge-controls')?.classList.remove('ge-transform-active');
|
||||||
state.transformPopup.innerHTML = '';
|
state.transformPopup.innerHTML = '';
|
||||||
state.transformPopup.style.display = 'none';
|
state.transformPopup.style.display = 'none';
|
||||||
} catch {}
|
} catch {}
|
||||||
state.transformPopup = null;
|
state.transformPopup = null;
|
||||||
}
|
}
|
||||||
|
document.querySelectorAll('.ge-controls.ge-transform-active').forEach(c => c.classList.remove('ge-transform-active'));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mobileBoundsHandler = null;
|
let mobileBoundsHandler = null;
|
||||||
@@ -285,6 +287,11 @@ export function createTransformSession({
|
|||||||
// bar) instead of a floating popup over the canvas.
|
// bar) instead of a floating popup over the canvas.
|
||||||
const pop = document.getElementById('ge-transform-section');
|
const pop = document.getElementById('ge-transform-section');
|
||||||
if (!pop) return;
|
if (!pop) return;
|
||||||
|
const parentControls = pop.closest('.ge-controls');
|
||||||
|
if (parentControls) {
|
||||||
|
parentControls.classList.add('ge-transform-active');
|
||||||
|
parentControls.classList.remove('dismissed');
|
||||||
|
}
|
||||||
pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`;
|
pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`;
|
||||||
const title = pop.querySelector('.ge-adj-title');
|
const title = pop.querySelector('.ge-adj-title');
|
||||||
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
|
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ export function wireTopbar(deps) {
|
|||||||
(Math.abs(state.zoom - 1) < 0.001 && state.zoomViewMode !== 'fit')) fitZoom();
|
(Math.abs(state.zoom - 1) < 0.001 && state.zoomViewMode !== 'fit')) fitZoom();
|
||||||
else { state.zoom = 1; state.zoomViewMode = 'scale'; applyZoom(); }
|
else { state.zoom = 1; state.zoomViewMode = 'scale'; applyZoom(); }
|
||||||
});
|
});
|
||||||
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); applyZoom(); });
|
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
|
||||||
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); applyZoom(); });
|
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
|
||||||
|
|
||||||
// Export / Download / Project Save / Project Load.
|
// Export / Download / Project Save / Project Load.
|
||||||
document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery);
|
document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery);
|
||||||
|
|||||||
@@ -2650,7 +2650,14 @@ export function openEmailLibrary(opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wire events
|
// Wire events
|
||||||
document.getElementById('email-lib-close').addEventListener('click', closeEmailLibrary);
|
document.getElementById('email-lib-close').addEventListener('click', () => {
|
||||||
|
const modal = document.getElementById('email-lib-modal');
|
||||||
|
if (modal?.classList.contains('email-reading') || modal?.querySelector('.doclib-card-expanded')) {
|
||||||
|
_exitEmailReaderModeForList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeEmailLibrary();
|
||||||
|
});
|
||||||
document.getElementById('email-settings-header-back')?.addEventListener('click', _hideEmailSettingsPage);
|
document.getElementById('email-settings-header-back')?.addEventListener('click', _hideEmailSettingsPage);
|
||||||
|
|
||||||
// Clicking the modal header (anywhere except buttons/inputs) collapses
|
// Clicking the modal header (anywhere except buttons/inputs) collapses
|
||||||
@@ -5021,7 +5028,7 @@ export async function _refreshUnreadBadge({ unreadCountOverride = null, preserve
|
|||||||
} catch (_) { _syncUnreadTabBadge(0); }
|
} catch (_) { _syncUnreadTabBadge(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function _loadEmails({ force = false, useCache = true } = {}) {
|
export async function _loadEmails({ force = false, useCache = true, skipFastPath = false } = {}) {
|
||||||
const seq = ++_libLoadSeq;
|
const seq = ++_libLoadSeq;
|
||||||
state._libLoading = true;
|
state._libLoading = true;
|
||||||
const accountAtStart = state._libAccountId || '';
|
const accountAtStart = state._libAccountId || '';
|
||||||
@@ -5119,7 +5126,7 @@ export async function _loadEmails({ force = false, useCache = true } = {}) {
|
|||||||
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||||
const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : '';
|
const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : '';
|
||||||
const dateQS = `${dateFromAtStart ? `&date_from=${encodeURIComponent(dateFromAtStart)}` : ''}${dateToAtStart ? `&date_to=${encodeURIComponent(dateToAtStart)}` : ''}`;
|
const dateQS = `${dateFromAtStart ? `&date_from=${encodeURIComponent(dateFromAtStart)}` : ''}${dateToAtStart ? `&date_to=${encodeURIComponent(dateToAtStart)}` : ''}`;
|
||||||
if (!cached && cacheable && !force) {
|
if (!cached && cacheable && !force && !skipFastPath) {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 450);
|
const timer = setTimeout(() => ctrl.abort(), 450);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -73,16 +73,24 @@ function _isDangerousSrcset(value) {
|
|||||||
|
|
||||||
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
|
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
|
||||||
export function _escLinkify(text) {
|
export function _escLinkify(text) {
|
||||||
const escaped = _esc(text);
|
const raw = String(text || '');
|
||||||
// URLs: http(s)://... or www....
|
// Match raw text once so generated anchors are never linkified again.
|
||||||
const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])/g;
|
const tokenRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])|\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
|
||||||
const mailRe = /\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
|
const parts = [];
|
||||||
return escaped
|
let offset = 0;
|
||||||
.replace(urlRe, (m) => {
|
for (const match of raw.matchAll(tokenRe)) {
|
||||||
const href = m.startsWith('www.') ? `https://${m}` : m;
|
parts.push(_esc(raw.slice(offset, match.index)));
|
||||||
return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
|
const token = match[0];
|
||||||
})
|
const isUrl = !!match[1];
|
||||||
.replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
|
const href = isUrl
|
||||||
|
? (token.startsWith('www.') ? `https://${token}` : token)
|
||||||
|
: `mailto:${token}`;
|
||||||
|
const attrs = isUrl ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||||
|
parts.push(`<a href="${_attrEsc(_esc(href))}"${attrs}>${_esc(token)}</a>`);
|
||||||
|
offset = match.index + token.length;
|
||||||
|
}
|
||||||
|
parts.push(_esc(raw.slice(offset)));
|
||||||
|
return parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull display name out of "Name <email@x>"; fallback to local-part of
|
// Pull display name out of "Name <email@x>"; fallback to local-part of
|
||||||
|
|||||||
+57
-8
@@ -2229,6 +2229,36 @@ function _makeGalleryDraggable(content) {
|
|||||||
// Re-export the manager for the rail click handler
|
// Re-export the manager for the rail click handler
|
||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
|
|
||||||
|
// Module-owned listeners resolve the current picker; closed modals are never retained.
|
||||||
|
window.addEventListener('ge-ai-command-model-change', (e) => {
|
||||||
|
const imageModelSelect = document.getElementById('gallery-image-model');
|
||||||
|
if (!imageModelSelect || !imageModelSelect._modelsReady) return;
|
||||||
|
let val = (e && e.detail && typeof e.detail.value === 'string') ? e.detail.value : '';
|
||||||
|
if (val && ![...imageModelSelect.options].some(o => o.value === val)) {
|
||||||
|
if (localStorage.getItem('ge-ai-command-model') === val) localStorage.removeItem('ge-ai-command-model');
|
||||||
|
val = '';
|
||||||
|
}
|
||||||
|
if (imageModelSelect.value !== val) {
|
||||||
|
imageModelSelect.value = val;
|
||||||
|
if (imageModelSelect.value !== val) imageModelSelect.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('storage', (e) => {
|
||||||
|
const imageModelSelect = document.getElementById('gallery-image-model');
|
||||||
|
if (e && e.key === 'ge-ai-command-model' && imageModelSelect?._modelsReady) {
|
||||||
|
let val = e.newValue || '';
|
||||||
|
if (val && ![...imageModelSelect.options].some(o => o.value === val)) {
|
||||||
|
if (localStorage.getItem('ge-ai-command-model') === val) localStorage.removeItem('ge-ai-command-model');
|
||||||
|
val = '';
|
||||||
|
}
|
||||||
|
if (imageModelSelect.value !== val) {
|
||||||
|
imageModelSelect.value = val;
|
||||||
|
if (imageModelSelect.value !== val) imageModelSelect.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
export function openGallery() {
|
export function openGallery() {
|
||||||
// If already minimized — restore in place, preserve all state
|
// If already minimized — restore in place, preserve all state
|
||||||
if (Modals.isRegistered('gallery-modal') && Modals.isMinimized('gallery-modal')) {
|
if (Modals.isRegistered('gallery-modal') && Modals.isMinimized('gallery-modal')) {
|
||||||
@@ -2607,7 +2637,7 @@ export function openGallery() {
|
|||||||
|
|
||||||
// ── Image model (Settings tab) ──
|
// ── Image model (Settings tab) ──
|
||||||
// Mirror of the Quick Edit picker: same source (GET /api/editor/image-models)
|
// Mirror of the Quick Edit picker: same source (GET /api/editor/image-models)
|
||||||
// and the SAME localStorage key `ge-ai-command-model` (value `<base_url>::<id>`)
|
// and the SAME localStorage key `ge-ai-command-model` (value `<endpoint_id>::<id>`)
|
||||||
// so the two stay in sync.
|
// so the two stay in sync.
|
||||||
const _GE_MODEL_KEY = 'ge-ai-command-model';
|
const _GE_MODEL_KEY = 'ge-ai-command-model';
|
||||||
const imageModelSelect = document.getElementById('gallery-image-model');
|
const imageModelSelect = document.getElementById('gallery-image-model');
|
||||||
@@ -2617,7 +2647,8 @@ export function openGallery() {
|
|||||||
const r = await fetch(`${API_BASE}/api/editor/image-models`, { credentials: 'same-origin' });
|
const r = await fetch(`${API_BASE}/api/editor/image-models`, { credentials: 'same-origin' });
|
||||||
const data = r.ok ? await r.json() : null;
|
const data = r.ok ? await r.json() : null;
|
||||||
const models = (data && Array.isArray(data.models)) ? data.models : [];
|
const models = (data && Array.isArray(data.models)) ? data.models : [];
|
||||||
const saved = localStorage.getItem(_GE_MODEL_KEY) || '';
|
const rawSaved = localStorage.getItem(_GE_MODEL_KEY) || '';
|
||||||
|
let saved = rawSaved;
|
||||||
imageModelSelect.innerHTML = '';
|
imageModelSelect.innerHTML = '';
|
||||||
const def = document.createElement('option');
|
const def = document.createElement('option');
|
||||||
def.value = '';
|
def.value = '';
|
||||||
@@ -2625,12 +2656,34 @@ export function openGallery() {
|
|||||||
imageModelSelect.appendChild(def);
|
imageModelSelect.appendChild(def);
|
||||||
for (const m of models) {
|
for (const m of models) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = `${m.base_url || ''}::${m.id}`;
|
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : '');
|
||||||
|
opt.value = endpointKey ? `${endpointKey}::${m.id}` : m.id;
|
||||||
opt.textContent = `${m.id} · ${m.provider || ''} (${m.kind || 'edit'})`.trim();
|
opt.textContent = `${m.id} · ${m.provider || ''} (${m.kind || 'edit'})`.trim();
|
||||||
imageModelSelect.appendChild(opt);
|
imageModelSelect.appendChild(opt);
|
||||||
}
|
}
|
||||||
|
imageModelSelect._modelsReady = true;
|
||||||
|
// Invalidate or migrate legacy localStorage values containing URLs or leading ::
|
||||||
|
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
|
||||||
|
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
|
||||||
|
const matching = models.filter(m => m.id === modelPart);
|
||||||
|
if (matching.length === 1) {
|
||||||
|
const epKey = matching[0].endpoint_id ? String(matching[0].endpoint_id) : (matching[0].source === 'local' ? 'local' : '');
|
||||||
|
saved = epKey ? `${epKey}::${matching[0].id}` : matching[0].id;
|
||||||
|
} else {
|
||||||
|
saved = '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (saved) localStorage.setItem(_GE_MODEL_KEY, saved);
|
||||||
|
else localStorage.removeItem(_GE_MODEL_KEY);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
imageModelSelect.value = saved;
|
imageModelSelect.value = saved;
|
||||||
if (imageModelSelect.value !== saved) imageModelSelect.value = '';
|
if (imageModelSelect.value !== saved) imageModelSelect.value = '';
|
||||||
|
if (imageModelSelect.value !== rawSaved) {
|
||||||
|
if (imageModelSelect.value) localStorage.setItem(_GE_MODEL_KEY, imageModelSelect.value);
|
||||||
|
else localStorage.removeItem(_GE_MODEL_KEY);
|
||||||
|
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: imageModelSelect.value } }));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
imageModelSelect.innerHTML = '<option value="">Default (configured provider)</option>';
|
imageModelSelect.innerHTML = '<option value="">Default (configured provider)</option>';
|
||||||
}
|
}
|
||||||
@@ -2645,11 +2698,7 @@ export function openGallery() {
|
|||||||
});
|
});
|
||||||
_fillImageModels();
|
_fillImageModels();
|
||||||
}
|
}
|
||||||
window.addEventListener('storage', (e) => {
|
|
||||||
if (e && e.key === _GE_MODEL_KEY && imageModelSelect) {
|
|
||||||
imageModelSelect.value = e.newValue || '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Tag All Untagged ──
|
// ── Tag All Untagged ──
|
||||||
let _tagCancelRequested = false;
|
let _tagCancelRequested = false;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user