Compare commits

..
Author SHA1 Message Date
Alexandre Teixeira 0243a11e2b fix(history): reject falsy non-string message IDs; drop redundant keep_count guard
The or-chain used to resolve before_msg_id / message_id evaluated
falsy non-strings (False, 0) to '' before the isinstance(…, str) check
was reached, so they silently bypassed type validation.  Replace the
chain with explicit presence-aware logic that falls through on None or
empty string (preserving alias semantics) but rejects any other
non-string value with HTTP 400.

Also remove the now-tautological 'if keep_count >= 0:' guard, which
is always True after the negativity check introduced in the parent
commit.  The DB block is unconditional and indented accordingly.

Tests: add {'before_msg_id': False}, {'before_msg_id': 0},
{'message_id': 0} to the parametrised rejection suite.

36 passed, 0 failed (Python 3.14.4 / pytest 9.1.1).
2026-10-14 00:00:40 +01:00
robyroro 3365d5bdc5 fix(history): reject implicit and coerced truncation targets 2026-10-07 04:18:16 +03:00
63 changed files with 373 additions and 1276 deletions
+7 -22
View File
@@ -147,15 +147,8 @@ jobs:
if: steps.docs-check.outputs.docs_only != 'true'
with:
python-version: "3.11"
# uv resolves the same versions as pip but installs ~5x faster, and its
# cache keeps each shard from re-downloading the whole requirement set.
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
if: steps.docs-check.outputs.docs_only != 'true'
with:
enable-cache: true
cache-dependency-glob: requirements.txt
prune-cache: false
- run: uv pip install --system -r requirements.txt
cache: pip
- run: pip install -r requirements.txt
if: steps.docs-check.outputs.docs_only != 'true'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.docs-check.outputs.docs_only != 'true'
@@ -164,25 +157,15 @@ jobs:
cache: npm
- run: npm ci
if: steps.docs-check.outputs.docs_only != 'true'
# Browser binaries are keyed on the lockfile's Playwright version. On a
# hit, --with-deps still installs the system libraries but skips the
# browser downloads.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: steps.docs-check.outputs.docs_only != 'true'
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
if: steps.docs-check.outputs.docs_only != 'true'
- run: mkdir -p data # sqlite DB lives at ./data/app.db
if: steps.docs-check.outputs.docs_only != 'true'
# One apt pass for FFmpeg (media integration tests) and bubblewrap
# (containment, verified in the next step).
- name: Install FFmpeg and bubblewrap
- name: Install FFmpeg for media integration tests
if: steps.docs-check.outputs.docs_only != 'true'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap
sudo apt-get install -y --no-install-recommends ffmpeg
command -v ffmpeg
ffmpeg -version | head -n 1
@@ -191,6 +174,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends bubblewrap
bwrap --version
sysctl kernel.unprivileged_userns_clone user.max_user_namespaces \
kernel.apparmor_restrict_unprivileged_userns
@@ -238,4 +223,4 @@ jobs:
if: steps.docs-check.outputs.docs_only != 'true'
env:
PYTEST_SHARD: ${{ matrix.shard }}
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
run: python -m pytest -q -rs --shard "$PYTEST_SHARD"
-21
View File
@@ -67,24 +67,3 @@ SAN-191 reconciles references, font preferences, catalogs, tests and packaging.
The service-worker cache version changes so activation deletes prior app caches.
Omission is not a finding of infringement and does not grant permission to
restore the removed originals.
## Post-publication restoration review
The historical SAN entries recorded above reflect the conservative posture adopted during publication migration. As noted in SAN-191, omission was not a finding of infringement. A subsequent maintainer review traced these 14 project-origin assets through repository history, recorded their repository provenance, and restored their byte-identical historical versions previously distributed by the project.
The original SHA-256 hashes are recorded below. Ambiguous third-party assets (such as vendor marks and third-party libraries) remain deliberately omitted.
- RESTORE-001: `assets/branding/odysseus-browser.jpg` (SHA-256: `96bc894113d3db1a8e19535047e85fef7bfe7e643d45973d6effb945dce667df`): README interface screenshot introduced by pewdiepie-archdaemon in commit "Refresh README screenshot".
- RESTORE-002: `assets/branding/odysseus-wordmark.png` (SHA-256: `5e20708f9709735c66692c0cffc74734d61cfa256d48f9095c6870bcd53f4552`): README wordmark artwork introduced by pewdiepie-archdaemon in commit "Refresh README presentation".
- RESTORE-003: `assets/branding/odysseus.jpg` (SHA-256: `cd5e87b12f1e9e7baca6a6c5e95012130c9e26c6e31526e864eaf17096fcd05e`): Project branding image from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon, used by build-macos-app.sh for launcher icon generation.
- RESTORE-004: `website/bg.webm` (SHA-256: `157f732539a6072d55164da23d65afa9423782728dd067154867fcebb176fb35`): Landing page background video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-005: `website/chat.webm` (SHA-256: `d69f319192cec2cac1831e08ee99ae902398f1b163021e42b3fda05a0d7ab4d5`): Chat demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-006: `website/compare.webm` (SHA-256: `90d8c68dbc2ac3029b258598a7e2c2c5c8f5d6d702b9d31f0c729e5517093f51`): Model comparison demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-007: `website/document.webm` (SHA-256: `754e4ae071e9def5151749a03ad284cd41385354620fc8afea3c5c4f50f64a0d`): Document editor demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-008: `website/gallery.webm` (SHA-256: `b3bbe99c55b5cd1de32cb8e84fa31044761b7724a76a4bae4d4b0acaed7f3500`): Image gallery demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-009: `website/notes.webm` (SHA-256: `2a82b501271c4b60927e575bb22f8c986e3d1a4089a5518f84b46e534dea37d7`): Notes demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-010: `website/research.webm` (SHA-256: `2495a1a6cc1ea037f7460d065e3af6aacf091b240edebc436ce4bca5e83dab87`): Deep research demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-011: `website/theme.webm` (SHA-256: `6bd5a4140e9eeb9e06bee217319d331e9c8c873763fcb9040425fb38878b8410`): Theme demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
- RESTORE-012: `static/icons/icon-192.png` (SHA-256: `d9f54e07d2a8dca6302125d84436176a26a056745c5adb29ef521345c161e359`): Project PWA icon (192x192) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
- RESTORE-013: `static/icons/icon-512.png` (SHA-256: `785872b140da58087f23539ed187fe0f69de6eb9690abf995b10c93b5b518388`): Project PWA icon (512x512) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
- RESTORE-014: `static/icons/icon-maskable-512.png` (SHA-256: `7ed567fe0de6b6b451eec35c8fb2b465d84a3b23945135c19c4fa388ac976449`): Project PWA maskable icon (512x512) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
+2 -8
View File
@@ -1,6 +1,4 @@
<p align="center">
<img src="assets/branding/odysseus-wordmark.png" alt="Odysseus" width="238">
</p>
<h1 align="center">Odysseus</h1>
<p align="center">
A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
@@ -17,10 +15,6 @@
<a href="https://repology.org/project/odysseus-ai/versions"><img src="https://repology.org/badge/vertical-allrepos/odysseus-ai.svg" alt="Packaging status"></a>
</p>
<p align="center">
<img src="assets/branding/odysseus-browser.jpg" alt="Odysseus interface">
</p>
---
## Quick Start
@@ -59,7 +53,7 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration
## Demo
A full hover-to-play tour lives on the [Odysseus landing page](https://odysseus-dev.github.io/odysseus/). Its source lives under [`website/`](website/).
The [Odysseus landing page](https://odysseus-dev.github.io/odysseus/) gives a text-only overview of each feature. Its source lives under [`website/`](website/).
## Contributing
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

+2 -18
View File
@@ -27,23 +27,8 @@ echo " port: $PORT"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
# ── Icon (best effort) -- center-crop the branding image to a square .icns ──
if [ -f "$REPO_DIR/assets/branding/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
TMPIMG="$(mktemp -d)"
# Center-crop to a square, scale to 512 (sips' icns encoder caps at 512), and
# let sips emit the .icns directly: more robust across macOS versions than
# building an .iconset by hand.
sips -c 720 720 "$REPO_DIR/assets/branding/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/assets/branding/odysseus.jpg" "$TMPIMG/sq.png"
sips -z 512 512 "$TMPIMG/sq.png" --out "$TMPIMG/icon.png" >/dev/null 2>&1
if sips -s format icns "$TMPIMG/icon.png" --out "$APP/Contents/Resources/odysseus.icns" >/dev/null 2>&1; then
echo " icon: odysseus.icns"
else
echo " icon: (skipped - conversion failed)"
fi
rm -rf "$TMPIMG"
else
echo " icon: (skipped - no assets/branding/odysseus.jpg)"
fi
# Use the macOS default application icon; no branding-derived artwork is bundled.
echo " icon: macOS default"
cp -R "$REPO_DIR/licenses" "$APP/Contents/Resources/licenses"
cp "$REPO_DIR/THIRD_PARTY_PROVENANCE.json" "$REPO_DIR/ACKNOWLEDGMENTS.md" "$APP/Contents/Resources/"
@@ -60,7 +45,6 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundlePackageType</key> <string>APPL</string>
<key>CFBundleExecutable</key> <string>$APP_NAME</string>
<key>CFBundleIconFile</key> <string>odysseus</string>
<key>LSMinimumSystemVersion</key> <string>11.0</string>
<key>NSHighResolutionCapable</key> <true/>
<key>LSUIElement</key> <false/>
+46 -19
View File
@@ -391,26 +391,53 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
async def truncate_session(request: Request, session_id: str):
_verify_session_owner(request, session_id)
try:
body = await request.json()
keep_count = int(body.get("keep_count", 0))
before_msg_id = str(body.get("before_msg_id") or body.get("message_id") or "").strip()
try:
body = await request.json()
except json.JSONDecodeError:
raise HTTPException(400, "Request body must be valid JSON")
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
# Resolve before_msg_id / message_id alias with explicit presence checks.
# None (JSON null) and empty string are both treated as "not provided here"
# and fall through to the alias, preserving the original OR-chain semantics.
# Any other non-string value (False, 0, True, 123, list, …) is rejected
# immediately so falsy non-strings cannot bypass type validation.
raw_msg_id = body.get("before_msg_id") if "before_msg_id" in body else None
if raw_msg_id is None or raw_msg_id == "":
raw_msg_id = body.get("message_id")
if raw_msg_id is not None and not isinstance(raw_msg_id, str):
raise HTTPException(400, "Message ID must be a string")
before_msg_id = (raw_msg_id or "").strip()
if "keep_count" not in body and not before_msg_id:
raise HTTPException(400, "keep_count or before_msg_id required")
raw_count = body.get("keep_count", 0)
# Keep integer-string clients working, but do not silently convert
# booleans or fractional numbers into destructive message counts.
if type(raw_count) not in (int, str):
raise HTTPException(400, "keep_count must be a non-negative integer")
try:
keep_count = int(raw_count)
except ValueError:
raise HTTPException(400, "keep_count must be a non-negative integer")
if keep_count < 0:
raise HTTPException(400, "keep_count must be a non-negative integer")
deleted_sft_pairs: list[dict[str, str]] = []
if keep_count >= 0:
db = SessionLocal()
try:
all_db_messages = db.query(DbChatMessage).filter(
DbChatMessage.session_id == session_id
).order_by(DbChatMessage.timestamp).all()
if before_msg_id:
resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id)
if resolved_keep_count is None:
raise HTTPException(404, "Message not found")
keep_count = resolved_keep_count
for pos, row in enumerate(all_db_messages):
row._will_delete_for_sft = pos >= keep_count
deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages)
finally:
db.close()
# keep_count is always >= 0 here; the block is unconditional.
db = SessionLocal()
try:
all_db_messages = db.query(DbChatMessage).filter(
DbChatMessage.session_id == session_id
).order_by(DbChatMessage.timestamp).all()
if before_msg_id:
resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id)
if resolved_keep_count is None:
raise HTTPException(404, "Message not found")
keep_count = resolved_keep_count
for pos, row in enumerate(all_db_messages):
row._will_delete_for_sft = pos >= keep_count
deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages)
finally:
db.close()
result = session_manager.truncate_messages(session_id, keep_count)
_remove_deleted_sft_trace_rows(
owner=effective_user(request),
+2 -2
View File
@@ -20,7 +20,7 @@ This spec covers the current browser app in:
`/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed.
`static/manifest.json`, `static/index.html`, and `static/login.html` reference first-party PWA icon files under `static/icons/` (192px, 512px, and maskable). At runtime, route-specific manifests can also be generated as Blob URLs when supported, and per-route favicon links use generated inline SVG.
The static PWA icon files (192px, 512px, maskable) were deliberately omitted from publication, and `static/icons/` is intentionally absent. `static/manifest.json` declares no `icons`, and neither `static/index.html` nor `static/login.html` ships a static `apple-touch-icon` link. Icons come from generated inline SVG at runtime: the `data:image/svg+xml` favicon, the `apple-touch-icon` link that `static/index.html` and `static/js/theme.js` create, and per-route Blob manifests whose `icons` point at that SVG. Do not reintroduce references to icon files that are not shipped.
## Current Call Sites Include
@@ -81,7 +81,7 @@ Storage/secrets policy:
- other static assets use cache-first with background refresh;
- `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes.
`static/manifest.json` owns default PWA metadata, referencing the restored icons under `static/icons/`. Route-specific manifests can be generated as Blob URLs when supported; dynamic icons use the route's generated inline SVG, while the default manifest uses the static icons.
`static/manifest.json` owns default PWA metadata and intentionally has no `icons` entry. Route-specific manifests can be generated as Blob URLs when supported; their icons are the route's generated inline SVG, not files.
KaTeX and Mermaid are self-hosted and lazy-loaded through memoized, retry-after-failure promises in `static/js/markdown.js`; math placeholders preserve source until KaTeX arrives, detached PDF export renders its own container, and Mermaid fetches only when a diagram exists. Pyodide remains a jsDelivr-loaded optional runtime, so offline/PWA behavior is not fully self-contained.
+9 -18
View File
@@ -801,18 +801,15 @@ class LsTool:
return {"error": f"ls: {e}", "exit_code": 1}
def _ls():
from src.agent_runtime.resources import _control_plane_snapshot
if not os.path.isdir(root):
return None, f"ls: {root}: not a directory"
rows = []
snapshot = _control_plane_snapshot()
try:
with os.scandir(root) as it:
for entry in it:
if entry.name.startswith("."):
continue
if (_is_denied_tool_path(os.path.realpath(entry.path), snapshot=snapshot)
or not _visible_bound_resource(entry.path)):
if _is_denied_tool_path(os.path.realpath(entry.path)) or not _visible_bound_resource(entry.path):
continue
try:
is_dir = entry.is_dir(follow_symlinks=False)
@@ -867,12 +864,10 @@ class GlobTool:
return {"error": f"glob: {e}", "exit_code": 1}
def _glob():
from src.agent_runtime.resources import _control_plane_snapshot
base = os.path.abspath(root)
if not os.path.isdir(base):
return None, f"glob: {root}: not a directory"
rbase = os.path.realpath(base)
snapshot = _control_plane_snapshot()
norm_pat = pattern.replace("\\", "/")
# Fast path: literal pattern (no wildcards) → direct path lookup.
if not any(c in norm_pat for c in "*?["):
@@ -889,8 +884,7 @@ class GlobTool:
# .ssh/id_rsa, …) falls through to the walk, which skips it —
# otherwise glob would surface secret paths that read_file /
# grep already refuse to touch.
if (inside and os.path.exists(cand) and not _is_denied_tool_path(cand, snapshot=snapshot)
and _visible_bound_resource(cand)):
if inside and os.path.exists(cand) and not _is_denied_tool_path(cand) and _visible_bound_resource(cand):
return [cand], None
# Literal not at exact path — fall through to walk so
# e.g. "foo.py" still matches at any depth (like rglob).
@@ -900,7 +894,7 @@ class GlobTool:
cap = _CODENAV_MAX_HITS * 5
try:
for dp, dns, fns in os.walk(base):
if not _can_traverse_tool_path(os.path.realpath(dp), snapshot=snapshot):
if not _can_traverse_tool_path(os.path.realpath(dp)):
dns[:] = []
continue
# Prune skipped dirs before descending (unlike rglob which
@@ -911,7 +905,7 @@ class GlobTool:
d for d in dns
if d not in _CODENAV_SKIP_DIRS
and d not in _SENSITIVE_BASENAMES
and _can_traverse_tool_path(os.path.realpath(os.path.join(dp, d)), snapshot=snapshot)
and _can_traverse_tool_path(os.path.realpath(os.path.join(dp, d)))
]
for name in fns + dns:
full = os.path.join(dp, name)
@@ -919,8 +913,7 @@ class GlobTool:
if regex.fullmatch(rel) or regex.fullmatch(name):
# Skip deny-listed sensitive files (.env, id_rsa,
# known_hosts, …) the same way grep does.
if (_is_denied_tool_path(os.path.realpath(full), snapshot=snapshot)
or not _visible_bound_resource(full)):
if _is_denied_tool_path(os.path.realpath(full)) or not _visible_bound_resource(full):
continue
try:
mtime = os.stat(full).st_mtime
@@ -988,7 +981,6 @@ class GrepTool:
import subprocess
import threading
from src.agent_runtime.resources import _control_plane_snapshot
from src.constants import DATA_DIR
rg = shutil.which("rg")
@@ -1000,7 +992,6 @@ class GrepTool:
if bound is not None:
bound.validate()
files = []
snapshot = _control_plane_snapshot()
def check_deadline():
if time.monotonic() >= deadline:
@@ -1011,7 +1002,7 @@ class GrepTool:
if os.path.islink(path):
return
canonical = os.path.realpath(path)
if not _path_within(canonical, base) or _is_denied_tool_path(canonical, snapshot=snapshot):
if not _path_within(canonical, base) or _is_denied_tool_path(canonical):
return
if bound is not None:
try:
@@ -1046,7 +1037,7 @@ class GrepTool:
while pending_directories:
check_deadline()
directory = pending_directories.pop()
if not _can_traverse_tool_path(directory, snapshot=snapshot):
if not _can_traverse_tool_path(directory):
continue
with os.scandir(directory) as entries:
for entry in entries:
@@ -1061,8 +1052,8 @@ class GrepTool:
raise ValueError("grep: directory identity changed during enumeration")
if entry.is_dir(follow_symlinks=False):
if (entry.name not in _CODENAV_SKIP_DIRS
and _can_traverse_tool_path(canonical, snapshot=snapshot)
and (bound is None or _is_denied_tool_path(canonical, snapshot=snapshot)
and _can_traverse_tool_path(canonical)
and (bound is None or _is_denied_tool_path(canonical)
or _visible_bound_resource(canonical))):
pending_directories.append(canonical)
else:
+9 -32
View File
@@ -2297,10 +2297,10 @@ async def action_audit_skills(owner: str, **kwargs) -> Tuple[str, bool]:
async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
"""Background note-due scanner. Fires a reminder for any note whose
`due_date` falls in the current ±90-second window and hasn't been delivered
through its configured channel in the last 25 minutes.
`due_date` falls in the current ±5-minute window and hasn't been pinged
within the last 25 minutes. Mirrors `action_ping_events` for calendar.
Per-owner state: {note_id: {at, channel}}, with legacy timestamp support. Pruned
State (`data/note_pings.json`): {note_id: iso_ts_of_last_ping}. Pruned
on each run by dropping entries for notes that are gone/archived/replied.
"""
try:
@@ -2309,11 +2309,6 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
from pathlib import Path as _P
from core.database import SessionLocal as _SL, Note as _N
from src.settings import load_settings
channel = load_settings().get("reminder_channel", "browser")
external_channel = channel in ("email", "ntfy", "webhook")
delivery_key = f"{channel}_sent" if external_channel else "browser_sent"
# Per-owner state file so cache-pruning doesn't cross-delete other
# users' entries (review C4). Legacy path kept as fallback so a
@@ -2378,27 +2373,20 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
due = _parse_due(n.due_date)
if not due:
continue
# Inside the due window?
# Inside the ±5min window?
if abs((due - now).total_seconds()) > window.total_seconds():
continue
# Recently pinged? Skip.
last = cache.get(n.id)
browser_recent = False
if last:
try:
last_channel = None
if isinstance(last, dict):
last_channel = last.get("channel")
last = last.get("at")
last_dt = _dt.fromisoformat(str(last))
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=_tz.utc)
if last_dt >= reping_cutoff:
if not external_channel or last_channel == channel:
continue
# Browser-only receipts do not prove external
# delivery, but the fallback must not be queued twice.
browser_recent = last_channel in (None, "browser")
continue
except Exception:
pass
# Compose + dispatch.
@@ -2422,26 +2410,15 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
body = "\n\n".join(p for p in body_parts if p) or title
try:
from routes.note_routes import dispatch_reminder
result = await dispatch_reminder(
await dispatch_reminder(
title=title, note_body=body, note_id=n.id,
owner=n.owner or owner or "",
queue_browser=not browser_recent,
)
if result.get("skipped"):
continue
if result.get(delivery_key):
sent.append(title)
else:
logger.warning("ping_notes: %s delivery failed for %s", channel, n.id)
cache[n.id] = now.isoformat()
sent.append(title)
except Exception as e:
logger.warning(f"ping_notes: dispatch failed for {n.id}: {e}")
# Dispatch owns delivery receipts. Reload before pruning so this
# scanner cannot replace a fresh channel receipt with a timestamp.
try:
cache = _json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {}
except Exception:
pass
# Prune cache entries for notes that no longer exist.
for stale in [k for k in cache if k not in seen_ids]:
cache.pop(stale, None)
@@ -2452,7 +2429,7 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
logger.warning(f"ping_notes: cache write failed: {e}")
if not sent:
raise TaskNoop(f"scanned {len(notes)} note(s), no reminders delivered")
raise TaskNoop(f"scanned {len(notes)} note(s), none due in ±{WINDOW_SEC}s")
preview = "; ".join(sent[:3])
extra = f" (+{len(sent) - 3} more)" if len(sent) > 3 else ""
return f"Pinged {len(sent)} note(s): {preview}{extra}", True
+3 -53
View File
@@ -96,41 +96,6 @@ _PUBLIC_PREVIEW_TOOL_ERRORS = {message: message for message in (
)}
def _schema_argument_hint(args, schema):
"""Describe a schema mismatch from the schema and the call's own arguments.
Rebuilt from a fresh validation rather than read off the caught exception,
so nothing the exception carries can reach the model.
"""
if not isinstance(schema, dict):
return ''
try:
validator = jsonschema.validators.validator_for(schema)(schema)
error = jsonschema.exceptions.best_match(validator.iter_errors(args))
except jsonschema.exceptions.SchemaError:
return ''
if error is None:
return ''
location = '.'.join(str(part) for part in error.absolute_path)[:80]
field = f"'{location}' " if location else ''
if error.validator == 'required' and isinstance(error.instance, dict):
missing = [str(name) for name in error.validator_value if name not in error.instance]
prefix = f'{location}.' if location else ''
hint = 'Missing required argument: ' + ', '.join(f"'{prefix}{name}'" for name in missing) + '.'
elif error.validator == 'type':
expected = error.validator_value
expected = ' or '.join(expected) if isinstance(expected, list) else str(expected)
hint = f'Argument {field}must be of type {expected}.'
elif error.validator == 'enum':
allowed = ', '.join(str(value) for value in error.validator_value[:12])
hint = f'Argument {field}must be one of: {allowed}.'
elif error.validator == 'additionalProperties':
hint = 'Remove arguments the offered tool schema does not define.'
else:
return ''
return f'{hint} Correct the call using the offered tool schema.'
def _public_preview_tool_error(exc, *, execution_attempted=False):
"""Keep useful domain guidance while withholding arbitrary diagnostics."""
if execution_attempted:
@@ -6817,10 +6782,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
result, desc, policy_denied, block = None, name, False, None
args = {}
execution_attempted = False
# Guidance a preview validator authored for this call. It
# is the only failure text returned verbatim; exception
# text goes through _public_preview_tool_error.
validator_error = ''
call_signature = None
semantic_scope = None
try:
@@ -6879,12 +6840,11 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
prior_outcome = browser_navigation_outcomes.get(requested_url)
if requested_url and prior_outcome and prior_outcome[1] >= 2:
calls += 1
validator_error = (
raise ValueError(
f'Opening {requested_url} twice reached the same page '
f'({prior_outcome[0]}). Do not repeat it; use the current '
'page evidence or a different navigation strategy.'
)
raise ValueError(validator_error)
if tool_type == 'web_search':
web_search_attempts += 1
if not native_workspace_enabled and web_search_attempts > 3:
@@ -6978,7 +6938,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
'This exact invalid call was repeated after two validation failures; '
'the tool is disabled for this turn.'
)
validator_error = semantic_error
raise ValueError(semantic_error)
if canonical(name) == 'bash':
command = str(args.get('command') or '')
@@ -6987,7 +6946,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
calls += 1
suppressed_tool_until_round['bash'] = round_number + 1
round_recovery_messages.append(sensitive_error)
validator_error = sensitive_error
raise ValueError(sensitive_error)
misused_native_tool = shell_native_tool_command_misuse(
command, round_offered,
@@ -7001,7 +6959,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
'with its offered schema.'
)
round_recovery_messages.append(recovery)
validator_error = recovery
raise ValueError(recovery)
semantic_scope = semantic_repeat_scope(name, args)
if (
@@ -7058,11 +7015,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
if canonical(name) in permanently_suppressed_tools:
calls += 1
terminal_suppression_violation = True
validator_error = (
raise ValueError(
f'{name} was disabled after repeated identical calls; '
'no further execution was attempted.'
)
raise ValueError(validator_error)
success_repeat_limit = (
private_browser_success_repeat_limit(args)
if tool_type == 'private_browser' else 1
@@ -7155,7 +7111,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
)
if artifact_code_error:
round_recovery_messages.append(artifact_code_error)
validator_error = artifact_code_error
raise ValueError(artifact_code_error)
decision = evaluate_preview_call(
name, args, latest_user,
@@ -7361,13 +7316,8 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
logging.getLogger(__name__).warning(
'Clean v3 tool call failed: %s', exc, exc_info=True,
)
if not validator_error and isinstance(exc, jsonschema.ValidationError) and schema:
validator_error = _schema_argument_hint(args, schema['function']['parameters'])
result = {
'error': (
validator_error if validator_error and not execution_attempted
else _public_preview_tool_error(exc, execution_attempted=execution_attempted)
),
'error': _public_preview_tool_error(exc, execution_attempted=execution_attempted),
'error_category': 'tool_execution_error' if execution_attempted else 'invalid_tool_arguments',
'exit_code': 1,
}
+5 -9
View File
@@ -964,28 +964,24 @@ def _is_hardlinked_regular_file(resolved: str) -> bool:
return stat.S_ISREG(target.st_mode) and getattr(target, "st_nlink", 1) > 1
def _is_denied_tool_path(resolved: str, *, snapshot=None) -> bool:
"""Apply every path deny to a canonical traversal result.
Directory scans pass one ``_control_plane_snapshot()`` for the whole walk;
rebuilding it per entry made grep/ls/glob linear in protected-state stats.
"""
def _is_denied_tool_path(resolved: str) -> bool:
"""Apply every path deny to a canonical traversal result."""
from src.agent_runtime.resources import _control_plane_path
return (
_is_sensitive_path(resolved)
or _is_app_state_path(resolved)
or _is_hardlinked_regular_file(resolved)
or _control_plane_path(resolved, snapshot=snapshot)
or _control_plane_path(resolved)
)
def _can_traverse_tool_path(resolved: str, *, snapshot=None) -> bool:
def _can_traverse_tool_path(resolved: str) -> bool:
"""Allow walking a denied state parent only to reach safe carve-outs."""
if _is_sensitive_path(resolved):
return False
if not _is_app_state_path(resolved):
from src.agent_runtime.resources import _control_plane_path
return not _control_plane_path(resolved, snapshot=snapshot)
return not _control_plane_path(resolved)
return any(
_path_within(readable, resolved)
for readable in _agent_readable_data_subdirs()
-19
View File
@@ -130,30 +130,11 @@ body {
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* GohuFont is the default UI face across themes; keep the shipped font local
so the default is available before custom-font discovery completes. */
@font-face { font-family: 'GohuFont'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/custom/GohuFont.ttf') format('truetype'); }
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
/* Self-hosted editor text fonts (SIL OFL 1.1). These are bundled so the text
tool renders distinct, expected faces on every device — phones (Android/iOS)
do not ship Arial/Verdana/Georgia/Times/Courier/Impact, which previously
collapsed every font choice onto the same system fallback. */
@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-SemiBold.woff2') format('woff2'); }
@font-face { font-family: 'Roboto'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Playfair Display'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Playfair Display'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Lobster'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Lobster-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Bebas Neue'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/BebasNeue-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto Mono'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto Mono'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Bold.woff2') format('woff2'); }
/* Code block baseline */
pre, code, .hljs {
font-size: 11px;
-1
View File
@@ -704,7 +704,6 @@ textarea.memory-add-input {
#serve-bulk-bar,
#gallery-bulk-bar,
#gallery-editor-drafts-bulk,
#notes-bulk-bar,
#memory-modal .memory-bulk-bar {
padding-right: 18px;
}
-6
View File
@@ -90,12 +90,6 @@
.lib-tabs,
.gallery-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;
overflow-x: auto !important;
overflow-y: hidden;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

-1
View File
@@ -13,7 +13,6 @@
in email bodies — was wrapping random digits in <a href="tel:..."> with
browser-default styling that didn't match the Odysseus theme. -->
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}">
window._odysseusLoadTime = Date.now();
(function(){
+1 -61
View File
@@ -18,13 +18,7 @@
'use strict';
// Click-as-button rows we want reachable by keyboard.
var ROW_SELECTOR = [
'#sidebar .list-item',
'#user-bar-profile',
'.sidebar-brand',
'.export-dropdown-item',
'.dropdown-item'
].join(',');
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
// Native interactive descendants. If a row contains one of these we must
// NOT give the row role="button" — a button inside a button is invalid
@@ -61,31 +55,6 @@
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
}
// Settings form labels are written as <label class="settings-label">Text</label>
// with the control as a sibling/descendant, but are not associated via for/id,
// so screen readers cannot name the inputs. Associate each label with its
// control when the mapping is unambiguous (a single form control per row).
var settingsLabelSeq = 0;
function associateSettingsLabels(root) {
(root || document).querySelectorAll('.settings-label').forEach(function (label) {
if (label.hasAttribute('for')) return;
var control = null;
var sib = label.nextElementSibling;
if (sib && /^(INPUT|SELECT|TEXTAREA)$/.test(sib.tagName)) {
control = sib;
} else {
var row = label.closest('.settings-row') || label.parentElement;
if (row) {
var controls = row.querySelectorAll('input,select,textarea');
if (controls.length === 1) control = controls[0];
}
}
if (!control || control.closest('label')) return;
if (!control.id) control.id = 'a11y-settings-' + (++settingsLabelSeq);
if (!control.getAttribute('aria-label')) label.setAttribute('for', control.id);
});
}
// ---- Modal dialogs -----------------------------------------------------
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
// them as ARIA dialogs lets screen readers announce them as dialogs and
@@ -149,38 +118,9 @@
el.click();
});
// Disclosure buttons: keep aria-expanded in sync with their controlled
// menu/dropdown, regardless of which module toggles its visibility.
var DISCLOSURE_MAP = [
{ button: 'session-sort-btn', menu: 'session-sort-dropdown' },
{ button: 'export-dl-btn', menu: 'export-dropdown-menu' },
{ button: 'overflow-plus-btn', menu: 'overflow-menu' }
];
function syncDisclosures() {
DISCLOSURE_MAP.forEach(function (d) {
var btn = document.getElementById(d.button);
if (!btn) return;
var menu = document.getElementById(d.menu);
var open = false;
if (menu) {
var cs = window.getComputedStyle(menu);
open = cs.display !== 'none' && menu.offsetWidth > 0;
}
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
function init() {
enhanceAll(document);
enhanceModals(document);
associateSettingsLabels(document);
// Any click (the dropdown toggles, outside-click closes, etc.) may change
// a disclosure's open state; re-sync on the next tick.
document.addEventListener('click', function () {
if (window.setTimeout) setTimeout(syncDisclosures, 0);
});
// Sidebar content is re-rendered as the user navigates (session lists,
// tool sub-rows, etc.). Watch for new rows and enhance them too.
+9 -9
View File
@@ -499,7 +499,7 @@ function _eventSourceHtml(ev) {
// calendar name). The calendar-initial badge alone is multi-calendar UI.
if (ev.source_email_uid && ev.source_email_folder) {
const href = `#email=${encodeURIComponent(ev.source_email_folder)}:${encodeURIComponent(ev.source_email_uid)}`;
return `<a class="cal-event-source cal-event-source-email" href="${_e(href)}" title="Open source email" aria-label="Open source email"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"></rect><polyline points="3 7 12 13 21 7"></polyline></svg></a>`;
return `<a class="cal-event-source cal-event-source-email" href="${_e(href)}" title="Open source email" aria-label="Open source email" onclick="event.stopPropagation();"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"></rect><polyline points="3 7 12 13 21 7"></polyline></svg></a>`;
}
if (_calendars.length <= 1) return '';
const cal = _calendars.find(c => c.href === ev.calendar_href);
@@ -764,7 +764,7 @@ function _getModal() {
<div class="modal-content cal-modal-content">
<div class="modal-header">
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Calendar</h4>
<button class="close-btn" id="cal-close" aria-label="Close">✖</button>
<button class="close-btn" id="cal-close">✖</button>
</div>
<div class="modal-body" id="cal-body"></div>
<button class="cal-side-nav cal-side-nav-prev" id="cal-side-prev" title="Previous" aria-label="Previous">&lsaquo;</button>
@@ -897,7 +897,7 @@ function _updateDaySearchResults() {
// Re-wire click handlers on the newly-inserted event rows.
dayDetail.querySelectorAll('.cal-event-item').forEach(it => {
it.addEventListener('click', (e) => {
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) return;
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
const ev = _events.find(x => x.uid === it.dataset.uid);
if (ev) _showEventForm(ev);
});
@@ -2230,7 +2230,7 @@ async function _renderAgenda() {
_wireAll(body);
_wireQuickDelete(body);
body.querySelectorAll('.cal-agenda-event').forEach(el => el.addEventListener('click', (e) => {
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) return;
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
const ev = _events.find(e => e.uid === el.dataset.uid);
if (ev) _showEventForm(ev);
}));
@@ -2295,7 +2295,7 @@ async function _renderSearch() {
_wireAll(body);
_wireQuickDelete(body);
body.querySelectorAll('.cal-agenda-event').forEach(el => el.addEventListener('click', (e) => {
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) return;
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
const ev = _allEvents[el.dataset.uid];
if (ev) _showEventForm(ev);
}));
@@ -2984,7 +2984,7 @@ function _wireAll(body) {
_render();
}));
body.querySelectorAll('.cal-event-item').forEach(it => it.addEventListener('click', (e) => {
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) return;
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
const ev = _events.find(e => e.uid === it.dataset.uid);
if (ev) _showEventForm(ev);
}));
@@ -4170,7 +4170,7 @@ function _locHTML(loc, description = '') {
if (meetingMatch && !/https?:\/\/(?:teams\.microsoft\.com|(?:[a-z0-9-]+\.)?zoom\.us|meet\.google\.com|(?:[a-z0-9-]+\.)?webex\.com|meet\.jit\.si)\//i.test(String(loc))) {
const meetingUrl = meetingMatch[0].replace(/[.,);\]]+$/, '');
const safeMeetingUrl = _e(meetingUrl);
return `<a class="cal-event-link" href="${safeMeetingUrl}" target="_blank" rel="noopener" title="Join meeting">${safeMeetingUrl}</a>`;
return `<a href="${safeMeetingUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation();" title="Join meeting">${safeMeetingUrl}</a>`;
}
const urlRe = /(https?:\/\/[^\s<>"']+)/gi;
if (urlRe.test(loc)) {
@@ -4182,14 +4182,14 @@ function _locHTML(loc, description = '') {
const url = match[0];
html += _e(String(loc).slice(offset, match.index));
const safe = _e(url);
html += `<a class="cal-event-link" href="${safe}" target="_blank" rel="noopener">${safe}</a>`;
html += `<a href="${safe}" target="_blank" rel="noopener" onclick="event.stopPropagation();">${safe}</a>`;
offset = match.index + url.length;
}
return (html + _e(String(loc).slice(offset))).replace(/\n/g, '<br>');
}
// No URL — link the whole thing to OpenStreetMap.
const mapUrl = 'https://www.openstreetmap.org/search?query=' + encodeURIComponent(loc);
return `<a class="cal-event-link" href="${mapUrl}" target="_blank" rel="noopener" title="Open in OpenStreetMap">${_e(loc)}</a>`;
return `<a href="${mapUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation();" title="Open in OpenStreetMap">${_e(loc)}</a>`;
}
// ── Open / Close ──
+1 -69
View File
@@ -44,66 +44,6 @@ 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 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
// /api/upload/<id>). Used by the "Open in editor & apply" action.
function _currentAttachedImageUrl() {
try {
const box = document.getElementById('chat-history');
if (!box) return null;
// Use the newest user bubble that carries an image preview. Note
// ':last-of-type' is wrong here (it selects the last sibling of the
// element type, and the assistant reply follows the user bubble), so
// walk the user bubbles from the end instead.
const users = box.querySelectorAll('.msg-user');
for (let i = users.length - 1; i >= 0; i--) {
const img = users[i].querySelector('.attach-image-preview[data-file-id] img');
if (img && img.getAttribute('src')) return img.getAttribute('src');
}
// Fall back to any attachment preview/card in the history (newest).
const previews = box.querySelectorAll(
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
);
if (previews.length) {
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;
}
}
// Render the one-click "Open in editor & apply" button on a photo-edit reply
// that had no open editor (photo_editor_action kind 'open_and_apply').
function renderPhotoEditorApplyButton(container, action) {
if (!container || !action || action.kind !== 'open_and_apply') return;
if (container.querySelector('.chat-open-in-editor-btn')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chat-open-in-editor-btn';
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.addEventListener('click', async () => {
btn.disabled = true;
try {
if (typeof window.gePhotoEditorApply !== 'function') {
await import('/static/js/galleryEditor.js');
}
await window.gePhotoEditorApply({
imageId: null,
imageUrl: _currentAttachedImageUrl(),
prompt: action.prompt || '',
displayName: 'Chat edit',
});
} catch (err) {
console.error('[chat] open_and_apply failed', err);
btn.disabled = false;
}
});
container.appendChild(btn);
}
let API_BASE = '';
let currentAbort = null;
let isStreaming = false;
@@ -4087,7 +4027,6 @@ import { invalidateSettings } from './appConfig.js';
threadAbove.classList.add('has-bottom');
}
terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope);
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
lastContentRoundHolder = roundHolder || lastContentRoundHolder;
uiModule.scrollHistory();
continue;
@@ -6142,13 +6081,7 @@ import { invalidateSettings } from './appConfig.js';
cache: 'no-store',
});
if (!_getForegroundStreamState() || _backgroundStreams.has(sid)) return;
// The server now reports "no active stream" as 200 {status:"idle"} (an
// older server used 404); a body with status "streaming" means it is NOT
// stale. Accept both so the watchdog keeps working across deploys.
let _probe = null;
try { _probe = await res.json(); } catch (_) { _probe = null; }
const _noActiveStream = res.status === 404 || !_probe || _probe.status !== 'streaming';
if (!_noActiveStream) return;
if (res.status !== 404) return;
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
if (active.abortCtrl && !active.abortCtrl.signal.aborted) {
@@ -6510,7 +6443,6 @@ import { invalidateSettings } from './appConfig.js';
gotDelta = true;
try { spinner.destroy(); } catch (_) {}
renderDelta(json);
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
replayRendering.settle();
} else if (json.delta) {
if (!replayRendering.accepts(json)) continue;
+3 -3
View File
@@ -390,11 +390,11 @@ export function runHTML(code, panel) {
* Main entry point — called when a Run button is clicked
*/
export function run(btn) {
const code = btn.getAttribute('data-code');
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
const pre = btn.closest('pre');
if (!pre) return;
const codeEl = pre.querySelector('code');
const code = codeEl ? codeEl.textContent : btn.getAttribute('data-code');
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
const panel = getOrCreatePanel(pre);
if (!code || !code.trim()) {
-15
View File
@@ -96,21 +96,6 @@ function computeSuggestions() {
];
}
// ── 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 [
{ 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' },
];
}
// ── Popover build ─────────────────────────────────────────────────────
function buildPopover() {
const p = document.createElement('div');
+3 -3
View File
@@ -2083,15 +2083,15 @@ export function _expandModelRow(row, modelData) {
const _HWFIT_ENGINE_GLYPHS = {
'': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line><circle cx="8" cy="6" r="2" fill="currentColor" stroke="none"></circle><circle cx="16" cy="12" r="2" fill="currentColor" stroke="none"></circle><circle cx="10" cy="18" r="2" fill="currentColor" stroke="none"></circle></svg>',
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"></path><path d="M14 4l4 9 3-9"></path></svg>',
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
sglang: '',
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"></path><path d="M16 6v12"></path><path d="M20 6v12"></path></svg>',
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"></path><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"></path></svg>',
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
ollama: '',
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"></path></svg>',
};
function _hwfitEngineGlyph(value) {
return _HWFIT_ENGINE_GLYPHS[value] || _HWFIT_ENGINE_GLYPHS[''];
return _HWFIT_ENGINE_GLYPHS[value] ?? _HWFIT_ENGINE_GLYPHS[''];
}
const _HWFIT_USECASE_GLYPHS = {
+2 -2
View File
@@ -1167,12 +1167,12 @@ async function _fetchDependencies() {
// icon (the name alone is fine for librosa, hf_transfer, etc.).
const _DEP_GLYPHS = {
vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
sglang: '<span aria-hidden="true" style="display:block;width:13px;height:13px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
sglang: '',
mlx_lm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
mflux: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>',
boogu_image_mlx: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 17c2.5-4 4.5-4 7 0"/><circle cx="9" cy="9" r="1"/><circle cx="15" cy="9" r="1"/></svg>',
llama_cpp: '<svg width="13" height="13" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
ollama: '<img src="/static/icons/ollama-mark-crop.png" alt="" aria-hidden="true" width="13" height="13" style="display:block;width:13px;height:13px;object-fit:contain;" />',
ollama: '',
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
krea_diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19V5"/><path d="M4 12h4"/><path d="M12 5l-7 7 7 7"/><path d="M14 19l3-14 3 14"/><path d="M15.3 13h3.4"/></svg>',
sam_mask: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>',
+6 -7
View File
@@ -1498,10 +1498,9 @@ function _rerenderCachedModels() {
const _ggufOptions = _ggufChoices.map(f =>
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
).join('');
const _minimaxM3Snapshot = '/home/pewds/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b';
const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
const _modelPathValue = _isMiniMaxM3 && (!_savedModelPath || _savedModelPath === repo) ? _minimaxM3Snapshot : _savedModelPath;
const _modelPathValue = _savedModelPath || _defaultServeModel;
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
// Build save slots
const _allPresets = _loadPresets();
@@ -2211,11 +2210,11 @@ function _rerenderCachedModels() {
// custom picker so the dropdown lists "[V] vLLM", "[⚡] SGLang", etc.
const _BACKEND_GLYPHS = {
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
sglang: '',
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
mlx_image: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="M21 15l-5-5L5 20"/><path d="M17.5 4v4M15.5 6h4"/></svg>',
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
ollama: '',
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
};
@@ -2235,7 +2234,7 @@ function _rerenderCachedModels() {
const opt = _backendSource?.querySelector(`option[value="${CSS.escape(v)}"]`);
const label = opt ? opt.textContent : v;
if (_backendBtnLabel) _backendBtnLabel.textContent = label;
if (_backendBtnIconSlot) _backendBtnIconSlot.innerHTML = _BACKEND_GLYPHS[v] || _BACKEND_GLYPHS.vllm;
if (_backendBtnIconSlot) _backendBtnIconSlot.innerHTML = _BACKEND_GLYPHS[v] ?? _BACKEND_GLYPHS.vllm;
}
function _renderBackendMenu() {
@@ -2243,7 +2242,7 @@ function _rerenderCachedModels() {
const items = Array.from(_backendSource.options).map(o => ({ value: o.value, label: o.textContent }));
_backendMenu.innerHTML = items.map(it => `
<button type="button" role="option" class="hwfit-backend-item" data-value="${it.value}" style="all:unset;display:flex;align-items:center;gap:8px;width:100%;padding:6px 9px;border-radius:5px;font-size:12px;cursor:pointer;color:var(--fg);box-sizing:border-box;">
<span class="hwfit-backend-item-icon" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));flex-shrink:0;">${_BACKEND_GLYPHS[it.value] || _BACKEND_GLYPHS.vllm}</span>
<span class="hwfit-backend-item-icon" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));flex-shrink:0;">${_BACKEND_GLYPHS[it.value] ?? _BACKEND_GLYPHS.vllm}</span>
<span class="hwfit-backend-item-label" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${it.label}</span>
</button>
`).join('');
+98 -130
View File
@@ -1996,17 +1996,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Active drop mode for the PDF toolbar — toolbar buttons set this; the
// next click on a page consumes it. null means clicks do nothing.
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
// 1.6 for one box, every text box dropped after that defaults to 1.6.
const _pdfLastLineHeight = new Map(); // docId -> number
@@ -2301,16 +2290,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// active (toolbar buttons set the mode). Without a mode, clicking does
// nothing — keeps page interactions predictable so users don't get
// 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) => {
const canPlace = placementStartedOnPage;
placementStartedOnPage = false;
if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return;
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return;
if (!_pdfDropMode) return;
const rect = pageWrap.getBoundingClientRect();
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
@@ -2442,11 +2423,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Set handle visibility together; clicking/tapping the annotation itself
// brings hidden controls back.
let handlesRequested = _isTouch;
const _setHandlesVisible = (show) => {
if (!_pdfAnnotationDragging) handlesRequested = show;
const dismissed = wrap.dataset.controlsDismissed === '1';
const v = (show && !dismissed && !_pdfAnnotationDragging) ? '' : 'none';
const v = (show && !dismissed) ? '' : 'none';
del.style.display = v;
grip.style.display = v;
resize.style.display = v;
@@ -2467,8 +2446,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
_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,
_refreshHandles: () => _setHandlesVisible(handlesRequested) };
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 };
if (kind === 'check') {
// Stamp checkmark — value is fixed, nothing to listen for.
@@ -2590,12 +2568,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ref._autoGrow = _autoGrow;
}
del.addEventListener('pointerdown', ev => {
ev.preventDefault();
ev.stopPropagation();
});
del.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_pushPdfUndoSnapshot();
_removeAnnotation(ref);
@@ -2607,7 +2580,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ev.stopPropagation();
_pushPdfUndoSnapshot();
try { grip.setPointerCapture(ev.pointerId); } catch (_) {}
_setPdfAnnotationDragging(true);
// Hide the × and resize handles while moving so they don't obscure the
// box — easier to see exactly where it lands. Restored on release.
del.style.display = 'none';
@@ -2626,14 +2598,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
_setPdfAnnotationDragging(false);
_setHandlesVisible(true);
_schedulePdfPaneSave();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
});
// Drag bottom-right corner to resize. Width/height stored as percentages.
@@ -2642,7 +2611,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ev.stopPropagation();
_pushPdfUndoSnapshot();
try { resize.setPointerCapture(ev.pointerId); } catch (_) {}
_setPdfAnnotationDragging(true);
// Hide the × and move handles while resizing — clean view of the box edge.
del.style.display = 'none';
grip.style.display = 'none';
@@ -2660,14 +2628,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
_setPdfAnnotationDragging(false);
_setHandlesVisible(true);
_schedulePdfPaneSave();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
});
// Text options menu — opened from the floating … button so the spacing
@@ -3451,9 +3416,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}).join('');
if (!changed && /<[^>]+>/.test(text) && typeof document !== 'undefined') {
const probe = document.createElement('div');
const probe = document.createElement('template');
probe.innerHTML = text;
const plain = (probe.innerText || probe.textContent || '').trim();
const plain = (probe.content.textContent || '').trim();
const plainClean = plain ? _sanitizeOutgoingEmailBody(plain) : plain;
if (plainClean !== plain) return plainClean;
}
@@ -3565,9 +3530,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
function _emailHtmlToPlainText(html) {
if (typeof document === 'undefined') return String(html || '');
const d = document.createElement('div');
const d = document.createElement('template');
d.innerHTML = String(html || '');
return d.innerText || d.textContent || '';
return d.content.textContent || '';
}
function _sanitizedRichTextHtml(rich) {
@@ -5428,15 +5393,20 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
row.dataset.id = item.id || '';
row.dataset.kind = kind;
if (kind === 'gallery') {
const src = item.url ? `${API_BASE}${item.url}` : '';
row.innerHTML = `
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
<span class="email-odysseus-attach-thumb">${src ? `<img src="${_escHtml(src)}" alt="">` : ''}</span>
<span class="email-odysseus-attach-thumb"></span>
<span class="email-odysseus-attach-main">
<span class="email-odysseus-attach-title">${_escHtml(label)}</span>
<span class="email-odysseus-attach-meta">${_escHtml(item.filename || 'image')}</span>
</span>
`;
if (item.url) {
const img = document.createElement('img');
img.alt = '';
img.src = `${API_BASE}${item.url}`;
row.querySelector('.email-odysseus-attach-thumb').appendChild(img);
}
} else {
row.innerHTML = `
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
@@ -6765,10 +6735,10 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
.trim();
};
const splitCurrent = _splitEmailReplyQuote(currentBody);
const ownBody = document.createElement('div');
const ownBody = document.createElement('template');
ownBody.innerHTML = String(splitCurrent.body || '');
const ownText = (ownBody.textContent || '').trim();
const isReplaceableDraft = (!ownText && !ownBody.querySelector('img,video,audio,iframe,table')) || /^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText);
const ownText = (ownBody.content.textContent || '').trim();
const isReplaceableDraft = (!ownText && !ownBody.content.querySelector('img,video,audio,iframe,table')) || /^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText);
if (!isReplaceableDraft) {
if (uiModule) uiModule.showToast('Reply already has text');
return;
@@ -7660,8 +7630,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
<button id="doc-email-discard-btn" class="email-discard-btn" title="Close email" style="display:inline-flex;align-items:center;gap:5px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Close</span></button>
<span style="flex:1"></span>
<div class="email-send-split">
<button type="button" id="doc-email-send-btn" class="email-send-btn email-send-main" title="Send email (Ctrl+Enter)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Send</button>
<button type="button" id="doc-email-send-caret" class="email-send-btn email-send-caret" title="More send options" aria-haspopup="true" aria-expanded="false"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>
<button type="button" id="doc-email-send-btn" class="email-send-btn email-send-main" title="Send email (Ctrl+Enter)" onpointerdown="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)" onmousedown="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)" onclick="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Send</button>
<button type="button" id="doc-email-send-caret" class="email-send-btn email-send-caret" title="More send options" aria-haspopup="true" aria-expanded="false" onpointerdown="window.odysseusEmailCaretIntent&&window.odysseusEmailCaretIntent(event)" onmousedown="window.odysseusEmailCaretIntent&&window.odysseusEmailCaretIntent(event)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>
<div id="doc-email-more-menu" class="email-more-menu" style="display:none">
<div class="dropdown-item-compact" id="doc-email-draft-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></span>Save Draft</div>
<div class="dropdown-item-compact" id="doc-email-schedule-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>Schedule Send...</div>
@@ -15095,20 +15065,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
return _docxReady;
}
let _html2pdfReady = null;
function ensureHtml2Pdf() {
if (_html2pdfReady) return _html2pdfReady;
if (window.html2pdf) return (_html2pdfReady = Promise.resolve());
_html2pdfReady = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '/static/lib/html2pdf.bundle.min.js';
s.onload = resolve;
s.onerror = () => reject(new Error('Failed to load PDF library'));
document.head.appendChild(s);
});
return _html2pdfReady;
}
function _getExportBaseName() {
const doc = docs.get(activeDocId);
const title = (doc && doc.title) || 'document';
@@ -15117,22 +15073,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
return safeName + ver;
}
function _downloadDocumentBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.style.display = 'none';
document.body.appendChild(anchor);
try {
anchor.click();
} finally {
anchor.remove();
// Mobile browsers may consume the URL after the click handler returns.
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
}
function exportDocument() {
if (!activeDocId) return;
const textarea = document.getElementById('doc-editor-textarea');
@@ -15153,7 +15093,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const ver = doc && doc.version ? `_v${doc.version}` : '';
const mime = lang === 'csv' ? 'text/csv' : lang === 'json' ? 'application/json' : _isRichTextLang(lang) ? 'text/html' : 'text/plain';
const blob = new Blob([textarea.value], { type: mime });
_downloadDocumentBlob(blob, safeName + ver + ext);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = safeName + ver + ext;
a.click();
URL.revokeObjectURL(a.href);
}
// "Import from device" — open a file picker, upload, and immediately open
@@ -15259,7 +15203,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
function showExportMenu(e, anchorRect) {
if (e) e.stopPropagation();
const anchor = e?.currentTarget || e?.target?.closest('button');
// Remove existing menu if any (toggle off) — tear it down through its
// registered dismiss so the outside-click listener + Escape-stack entry go.
const existing = document.getElementById('doc-export-menu');
@@ -15296,8 +15239,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
menu.style.top = (rect.bottom + 2) + 'px';
menu.style.right = (window.innerWidth - rect.right) + 'px';
menu.style.left = 'auto';
const pane = document.querySelector('.doc-editor-pane');
menu.style.zIndex = String(Math.max(topPortalZ(), (pane && parseInt(getComputedStyle(pane).zIndex, 10) || 0) + 1));
menu.style.zIndex = '9999';
const langLabel = lang ? lang.toUpperCase() : 'TXT';
// Form-backed markdown doc → primary export is the filled PDF, not the
@@ -15331,11 +15273,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// white paper preview; PDF is converted from its extracted text into an
// editable DOCX in the browser.
// PDF-backed documents already have a lossless filled-PDF export above.
// Running the generic html2pdf path would rebuild the extracted text and
// Running the generic browser print path would rebuild the extracted text and
// destroy the original page layout, images, and form structure.
if (!isForm) {
if (!isForm && (_isDocxLang(lang) || typeof window.print === 'function')) {
options.push({
label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF',
label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print / save PDF',
fn: _isDocxLang(lang) ? () => convertOriginalDocument('pdf') : exportAsPdf,
});
}
@@ -15363,25 +15305,16 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
});
document.body.appendChild(menu);
// Keep the footer menu inside the visible viewport, including with a keyboard open.
const viewport = window.visualViewport;
const left = (viewport?.offsetLeft || 0) + 8;
const top = (viewport?.offsetTop || 0) + 8;
const right = left + (viewport?.width || window.innerWidth) - 16;
const bottom = top + (viewport?.height || window.innerHeight) - 16;
menu.style.maxWidth = Math.max(0, right - left) + 'px';
menu.style.minWidth = '0';
menu.style.maxHeight = Math.max(0, bottom - top) + 'px';
menu.style.overflowY = 'auto';
// Flip above the anchor when there's no room below — the Export button now
// lives in the bottom footer, so the menu would otherwise drop off-screen.
const mh = menu.offsetHeight;
const y = rect.bottom + 2 + mh <= bottom ? rect.bottom + 2 : rect.top - mh - 2;
menu.style.top = Math.max(top, Math.min(y, bottom - mh)) + 'px';
menu.style.left = Math.max(left, Math.min(rect.right - menu.offsetWidth, right - menu.offsetWidth)) + 'px';
menu.style.right = 'auto';
if (rect.bottom + mh > window.innerHeight - 8) {
menu.style.top = 'auto';
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
}
// Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual node removal.
const close = bindMenuDismiss(menu, () => { menu.remove(); },
ev => !menu.contains(ev.target) && !anchor?.contains(ev.target));
const close = bindMenuDismiss(menu, () => { menu.remove(); });
}
function _richTextExportCss() {
@@ -15441,7 +15374,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const richStyles = _isRichTextLang(lang) ? `<style>${_richTextExportCss()}</style>` : '';
const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>${_esc(title)}</title>${richStyles}</head><body style="max-width:800px;margin:40px auto;font-family:sans-serif;line-height:1.6;padding:0 20px;background:#fff;color:#000;">\n${body}\n</body></html>`;
const blob = new Blob([html], { type: 'text/html' });
_downloadDocumentBlob(blob, _getExportBaseName() + '.html');
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = _getExportBaseName() + '.html';
a.click();
URL.revokeObjectURL(a.href);
if (uiModule) uiModule.showToast('Exported as HTML');
}
@@ -15503,15 +15440,13 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
if (!activeDocId) return;
const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return;
try {
await ensureHtml2Pdf();
} catch (e) {
if (uiModule) uiModule.showError('Failed to load PDF library');
if (typeof window.print !== 'function') {
if (uiModule) uiModule.showError('Browser printing is unavailable.');
return;
}
const lang = document.getElementById('doc-language-select')?.value || '';
const text = textarea.value || '';
// Render content as HTML for PDF
// Render content as HTML for the browser print dialog.
let html;
if (_isRichTextLang(lang)) {
html = text;
@@ -15524,28 +15459,57 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
html = '<pre style="white-space:pre-wrap;font-size:11px;font-family:monospace;color:#000;background:#fff;">' +
text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre>';
}
const container = document.createElement('div');
container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;';
container.innerHTML = html;
if (_isRichTextLang(lang)) {
const style = document.createElement('style');
style.textContent = _richTextExportCss();
container.prepend(style);
// Use a sandboxed same-origin frame: rich text can render, but scripts,
// event handlers and embedded plugins cannot execute in the print view.
document.getElementById('doc-browser-print-frame')?.remove();
const frame = document.createElement('iframe');
frame.id = 'doc-browser-print-frame';
frame.title = 'Document print view';
frame.setAttribute('sandbox', 'allow-same-origin allow-modals');
frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:800px;height:600px;border:0;';
document.body.appendChild(frame);
try {
const printDoc = frame.contentDocument;
printDoc.open();
printDoc.write('<!DOCTYPE html><html><head><meta charset="utf-8"></head><body></body></html>');
printDoc.close();
printDoc.title = _getExportBaseName();
const container = printDoc.createElement('div');
container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;';
container.innerHTML = html;
if (_isRichTextLang(lang)) {
const style = printDoc.createElement('style');
style.textContent = _richTextExportCss();
container.prepend(style);
}
// Typeset deferred math before copying it into the print document.
await markdownModule.renderMath(container);
const styles = [];
document.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
if (!link.href.includes('/katex/')) return;
const copy = printDoc.createElement('link');
copy.rel = 'stylesheet';
copy.href = link.href;
styles.push(new Promise((resolve, reject) => {
copy.onload = resolve;
copy.onerror = () => reject(new Error('Math styles could not be loaded'));
}));
printDoc.head.appendChild(copy);
});
const printStyle = printDoc.createElement('style');
printStyle.textContent = '@page { margin: 10mm; } body { margin: 0; } img { max-width: 100%; } pre { white-space: pre-wrap; overflow-wrap: anywhere; }';
printDoc.head.appendChild(printStyle);
printDoc.body.appendChild(printDoc.importNode(container, true));
await Promise.all(styles);
await printDoc.fonts.ready;
await Promise.all(Array.from(printDoc.images, img => img.decode().catch(() => {})));
frame.contentWindow.addEventListener('afterprint', () => frame.remove(), { once: true });
frame.contentWindow.focus();
frame.contentWindow.print();
} catch (error) {
frame.remove();
if (uiModule) uiModule.showError('Could not open browser print: ' + (error.message || error));
}
// This container is detached, so the document-scoped flush mdToHtml
// schedules never sees it. Typeset the deferred math before html2pdf
// rasterises, or the PDF gets raw formula source. renderMath() returns
// immediately, without loading KaTeX, when there is nothing pending.
await markdownModule.renderMath(container);
const baseName = _getExportBaseName();
window.html2pdf().set({
margin: 10,
filename: baseName + '.pdf',
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
}).from(container).save();
if (uiModule) uiModule.showToast('Exporting PDF...');
}
function _docxHexColor(value) {
@@ -15989,7 +15953,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
});
const blob = await window.docx.Packer.toBlob(doc);
const baseName = _getExportBaseName();
_downloadDocumentBlob(blob, baseName + '.docx');
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = baseName + '.docx';
a.click();
URL.revokeObjectURL(a.href);
if (uiModule) uiModule.showToast('Exported as DOCX');
}
-1
View File
@@ -66,4 +66,3 @@ export function parseMarkdownOutline(source) {
}
return entries;
}
+3 -38
View File
@@ -1302,11 +1302,8 @@ let _libSyncStatus = {
source: '',
warming: false,
loading: false,
stale: false,
};
let _libSyncTicker = null;
let _libStaleRefetchTimer = null;
let _libStaleRefetchCount = 0;
function _libSyncDateFrom(value) {
if (!value) return null;
@@ -1361,37 +1358,9 @@ function _setEmailSyncStatus(next = {}) {
if (Object.prototype.hasOwnProperty.call(next, 'loading')) {
_libSyncStatus.loading = Boolean(next.loading);
}
if (Object.prototype.hasOwnProperty.call(next, 'stale')) {
_libSyncStatus.stale = Boolean(next.stale);
}
_renderEmailSyncStatus();
}
// A server-side stale (sync.stale) response means the SWR snapshot was served
// while a background IMAP refresh is still running. Re-fetch in place after a
// short delay (bounded) so the fresh rows land with no skeleton and no scroll
// jump. We only keep retrying while the response is still marked stale.
function _clearStaleEmailRefetch() {
if (_libStaleRefetchTimer) {
clearTimeout(_libStaleRefetchTimer);
_libStaleRefetchTimer = null;
}
_libStaleRefetchCount = 0;
}
function _scheduleStaleEmailRefetch() {
if (_libStaleRefetchTimer || _libStaleRefetchCount >= 3) return;
const folderAtSchedule = state._libFolder;
const accountAtSchedule = state._libAccountId || '';
_libStaleRefetchCount++;
_libStaleRefetchTimer = setTimeout(() => {
_libStaleRefetchTimer = null;
if (document.hidden) return;
if (folderAtSchedule !== state._libFolder || accountAtSchedule !== (state._libAccountId || '')) return;
_loadEmails({ force: false, useCache: false, skipFastPath: true });
}, 2500);
}
function _libCacheKeyFor(accountId, folder, filter, hasAttachments) {
return [
accountId || '',
@@ -2242,7 +2211,7 @@ export function openEmailLibrary(opts = {}) {
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
<span>Back</span>
</button>
<button class="close-btn" id="email-lib-close" aria-label="Close">\u2716</button>
<button class="close-btn" id="email-lib-close">\u2716</button>
</div>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;overflow:hidden;">
@@ -4825,7 +4794,7 @@ export async function _refreshUnreadBadge({ unreadCountOverride = null, preserve
} catch (_) { _syncUnreadTabBadge(0); }
}
export async function _loadEmails({ force = false, useCache = true, skipFastPath = false } = {}) {
export async function _loadEmails({ force = false, useCache = true } = {}) {
const seq = ++_libLoadSeq;
state._libLoading = true;
const accountAtStart = state._libAccountId || '';
@@ -4879,15 +4848,11 @@ export async function _loadEmails({ force = false, useCache = true, skipFastPath
}
const stats = document.getElementById('email-lib-stats');
if (stats) stats.textContent = `${state._libTotal} emails`;
const _isStale = Boolean(sync.stale);
_setEmailSyncStatus({
updatedAt: sync.updated_at || '',
source: sync.source || (cacheSource ? 'client_cache' : ''),
loading: false,
stale: _isStale,
});
if (_isStale) _scheduleStaleEmailRefetch();
else _clearStaleEmailRefetch();
if (cacheable && !cacheSource) {
_libCachePut(ck, { emails: state._libEmails.slice(), total: state._libTotal, sync });
}
@@ -4923,7 +4888,7 @@ export async function _loadEmails({ force = false, useCache = true, skipFastPath
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : '';
const dateQS = `${dateFromAtStart ? `&date_from=${encodeURIComponent(dateFromAtStart)}` : ''}${dateToAtStart ? `&date_to=${encodeURIComponent(dateToAtStart)}` : ''}`;
if (!cached && cacheable && !force && !skipFastPath) {
if (!cached && cacheable && !force) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 450);
try {
+7 -97
View File
@@ -1278,7 +1278,6 @@ function _renderEditorLanding() {
<p>Start a blank canvas, or open a photo from your gallery to edit it.</p>
<div class="gallery-editor-landing-actions">
<button class="gallery-select-btn" id="gallery-editor-new">New canvas...</button>
<button class="gallery-select-btn" id="gallery-editor-import"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><path d="M12 3v12"/><path d="M7 8l5-5 5 5"/><path d="M5 21h14a2 2 0 0 0 2-2v-4"/><path d="M3 15v4a2 2 0 0 0 2 2"/></svg>Import</button>
<button class="gallery-select-btn" id="gallery-editor-pick"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Browse gallery</button>
</div>
<section class="gallery-editor-template-picker" aria-labelledby="gallery-editor-template-title">
@@ -1329,28 +1328,6 @@ function _renderEditorLanding() {
document.getElementById('gallery-editor-pick')?.addEventListener('click', () => {
document.querySelector('#gallery-modal .gallery-tab[data-tab="images"]')?.click();
});
document.getElementById('gallery-editor-import')?.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) return;
const name = (file.name || 'Imported image').replace(/\.[^.]+$/, '') || 'Imported image';
const reader = new FileReader();
reader.onload = async () => {
try {
await openEditor(reader.result, null, null, name);
} catch (e) {
console.error('[gallery] image import failed', e);
uiModule?.showError?.('Could not import that image');
}
};
reader.onerror = () => uiModule?.showError?.('Could not read that file');
reader.readAsDataURL(file);
});
input.click();
});
}
// Wire the first-tile Upload affordance in the Photos grid. Opens the same
@@ -1374,11 +1351,6 @@ function _wireUploadTile() {
});
input.click();
});
tile.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
e.preventDefault();
tile.click();
});
}
// Shimmer placeholder tiles shown while the FIRST page loads, so the grid
@@ -1404,7 +1376,7 @@ function _renderGrid() {
// tile in the Albums tab so the upload entry point is consistent across
// both grids.
const uploadTile = `
<div class="gallery-card gallery-card-upload" id="gallery-upload-tile" title="Upload photos or videos" tabindex="0" role="button" aria-label="Upload photos or videos">
<div class="gallery-card gallery-card-upload" id="gallery-upload-tile" title="Upload photos or videos">
<div class="gallery-card-upload-inner">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div class="gallery-card-upload-label">Upload</div>
@@ -1443,10 +1415,10 @@ function _renderGrid() {
_isVideoUrl(img.url) ? '<span class="gallery-card-badge">Video</span>' : '',
].filter(Boolean).join('');
html += `
<div class="gallery-card" data-id="${_esc(img.id)}" tabindex="0" role="button" aria-label="${_esc(labelText)}">
<div class="gallery-card" data-id="${_esc(img.id)}">
<span class="gallery-select-dot" style="display:none;"></span>
<button class="gallery-fav-btn${favCls}" data-id="${_esc(img.id)}" title="Favorite" aria-label="Favorite" aria-pressed="${favCls ? 'true' : 'false'}">&#9829;</button>
<button class="gallery-dl-btn" data-id="${_esc(img.id)}" data-url="${_esc(img.url)}" data-filename="${_esc(img.filename || '')}" title="Download" aria-label="Download">
<button class="gallery-fav-btn${favCls}" data-id="${_esc(img.id)}" title="Favorite">&#9829;</button>
<button class="gallery-dl-btn" data-id="${_esc(img.id)}" data-url="${_esc(img.url)}" data-filename="${_esc(img.filename || '')}" title="Download">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
${_isVideoUrl(img.url)
@@ -1492,14 +1464,6 @@ function _renderGrid() {
const img = _items.find(i => i.id === card.dataset.id);
if (img) _openDetail(img);
});
card.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
e.preventDefault();
const selectBtn = document.getElementById('gallery-select-btn');
if (selectBtn && selectBtn.classList.contains('active')) return;
const img = _items.find(i => i.id === card.dataset.id);
if (img) _openDetail(img);
});
});
// Download buttons
@@ -1611,7 +1575,7 @@ function _openDetail(img) {
<svg width="14" height="14" viewBox="0 0 24 24" fill="${img.favorite ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
</button>
<div class="gallery-detail-menu-wrap">
<button class="gallery-detail-action gallery-detail-menu-btn" id="gallery-detail-menu-btn" title="Actions" aria-label="Photo actions" aria-haspopup="menu" aria-expanded="false">
<button class="gallery-detail-action gallery-detail-menu-btn" id="gallery-detail-menu-btn" title="Actions" aria-label="Photo actions">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
</button>
<div class="gallery-detail-menu dropdown" id="gallery-detail-menu" hidden>
@@ -1840,7 +1804,7 @@ function _openDetail(img) {
const menu = document.getElementById('gallery-detail-menu');
if (menuBtn && menu) {
// `.dropdown { display:none }` isn't tied to [hidden] — set inline display.
const _setMenu = (show) => { menu.hidden = !show; menu.style.display = show ? 'block' : 'none'; menuBtn.setAttribute('aria-expanded', show ? 'true' : 'false'); };
const _setMenu = (show) => { menu.hidden = !show; menu.style.display = show ? 'block' : 'none'; };
_setMenu(false);
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -2271,7 +2235,7 @@ export function openGallery() {
<div class="modal-content gallery-modal-content">
<div class="modal-header">
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Gallery <span id="gallery-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal;margin-left:8px"></span></h4>
<button class="modal-close" id="gallery-close" aria-label="Close">&times;</button>
<button class="modal-close" id="gallery-close">&times;</button>
</div>
<div class="gallery-tabs">
<button class="gallery-tab active" data-tab="images">
@@ -2287,7 +2251,6 @@ export function openGallery() {
<span class="gallery-tab-label">Edit</span>
<span class="gallery-tab-close" id="gallery-editor-tab-close" title="Close edit" aria-label="Close edit">×</span>
</button>
<span class="gallery-editor-tabs-extra" id="gallery-editor-tabs-extra"></span>
<button class="gallery-tab" data-tab="settings">
<span class="gallery-tab-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="gallery-tab-label">Settings</span>
@@ -2355,13 +2318,6 @@ export function openGallery() {
</button>
</div>
</div>
<div class="admin-card">
<h2>Image model</h2>
<p class="memory-desc doclib-desc">Model used for AI edits and generated content in the editor (Quick Edit). Shared with the Quick Edit model picker.</p>
<select id="gallery-image-model" style="margin-top:8px;width:100%;">
<option value="">Loading…</option>
</select>
</div>
</div>
</div>
</div>
@@ -2602,52 +2558,6 @@ export function openGallery() {
});
}
// ── Image model (Settings tab) ──
// 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>`)
// so the two stay in sync.
const _GE_MODEL_KEY = 'ge-ai-command-model';
const imageModelSelect = document.getElementById('gallery-image-model');
if (imageModelSelect) {
const _fillImageModels = async () => {
try {
const r = await fetch(`${API_BASE}/api/editor/image-models`, { credentials: 'same-origin' });
const data = r.ok ? await r.json() : null;
const models = (data && Array.isArray(data.models)) ? data.models : [];
const saved = localStorage.getItem(_GE_MODEL_KEY) || '';
imageModelSelect.innerHTML = '';
const def = document.createElement('option');
def.value = '';
def.textContent = 'Default (configured provider)';
imageModelSelect.appendChild(def);
for (const m of models) {
const opt = document.createElement('option');
opt.value = `${m.base_url || ''}::${m.id}`;
opt.textContent = `${m.id} · ${m.provider || ''} (${m.kind || 'edit'})`.trim();
imageModelSelect.appendChild(opt);
}
imageModelSelect.value = saved;
if (imageModelSelect.value !== saved) imageModelSelect.value = '';
} catch (e) {
imageModelSelect.innerHTML = '<option value="">Default (configured provider)</option>';
}
};
imageModelSelect.addEventListener('change', () => {
const val = imageModelSelect.value || '';
try {
if (val) localStorage.setItem(_GE_MODEL_KEY, val);
else localStorage.removeItem(_GE_MODEL_KEY);
} catch (e) {}
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
});
_fillImageModels();
}
window.addEventListener('storage', (e) => {
if (e && e.key === _GE_MODEL_KEY && imageModelSelect) {
imageModelSelect.value = e.newValue || '';
}
});
// ── Tag All Untagged ──
let _tagCancelRequested = false;
let _tagging = false;
+1 -7
View File
@@ -582,19 +582,13 @@ export async function refreshModels(force = false, opts = {}) {
noModels.className = 'models-empty-state';
if (window._isAdmin) {
noModels.innerHTML = '<span class="muted">No models found</span><br>'
+ '<a href="#" id="models-open-admin" class="accent-link">Open Admin to add endpoints</a>'
+ '<a href="#" onclick="document.getElementById(\'user-bar-admin\')?.click();return false;" class="accent-link">Open Admin to add endpoints</a>'
+ '<br><span class="muted-sm">Type /setup for Local models or API setup.</span>';
} else {
noModels.innerHTML = '<span class="muted">No models available</span><br>'
+ '<span class="muted-sm">Ask an admin to configure model endpoints</span>';
}
box.appendChild(noModels);
const openAdmin = noModels.querySelector('#models-open-admin');
if (openAdmin) openAdmin.addEventListener('click', (e) => {
e.preventDefault();
const btn = document.getElementById('user-bar-admin');
if (btn) btn.click();
});
}
} catch (e) {
console.error(e);
+4 -4
View File
@@ -542,7 +542,7 @@ function _linkify(s) {
url = url.slice(0, -1);
}
const href = url.startsWith('www.') ? `https://${url}` : url;
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
});
}
function _uid() { return Math.random().toString(36).slice(2, 10); }
@@ -2335,7 +2335,7 @@ function _bindCardEvents(body) {
if (_selectMode) {
body.querySelectorAll('.note-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.note-card-cb, a')) return; // checkbox handles itself; links open without toggling
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
e.stopPropagation();
tapToEditOrSelect(card);
});
@@ -3070,7 +3070,7 @@ function _buildForm(note = null) {
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
</div>
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button></div>` : ''}
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button></div>` : ''}
<div class="note-form-body">
${type === 'note'
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
@@ -3651,7 +3651,7 @@ function _buildForm(note = null) {
form.querySelector('.note-form-image-wrap')?.remove();
const wrap = document.createElement('div');
wrap.className = 'note-form-image-wrap';
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button>`;
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button>`;
// Insert AFTER the whole header (a flex-row), not after the
// title input itself — otherwise the image lands as a sibling
// of the title inside the header and flex puts them side-by-side.
+1 -4
View File
@@ -80,10 +80,7 @@ export function init(apiBase) {
// sidebar — _reconnectActive only ran once at load before, so
// agent-started jobs never appeared until a page reload.
if (_activePollInterval) clearInterval(_activePollInterval);
_activePollInterval = setInterval(() => {
if (document.hidden) return;
_reconnectActive();
}, 60000);
_activePollInterval = setInterval(() => { _reconnectActive(); }, 20000);
}
// Allow an immediate adopt when the chat stream signals a new research
-3
View File
@@ -103,9 +103,6 @@ export function initSidebarLayout(Storage, opts) {
document.body.classList.toggle('hamburger-only', sidebarHidden && railHidden);
document.body.classList.toggle('sidebar-collapsed', sidebarHidden);
_setSidebarModeClasses(!sidebarHidden ? 'full' : (railHidden ? 'off' : 'mini'));
hamburgerBtn.setAttribute('aria-expanded', String(!sidebarHidden));
const sidebarToggleBtn = document.getElementById('sidebar-toggle-btn');
if (sidebarToggleBtn) sidebarToggleBtn.setAttribute('aria-expanded', String(!sidebarHidden));
}
// Keep incognito button clear of hamburger
const incogBtn = document.getElementById('incognito-btn');
+1 -1
View File
@@ -465,7 +465,7 @@ export function pick(opts = {}) {
if (!dataUrl) return '';
return `
<div class="sig-tile" data-id="${_esc(s.id)}">
<img src="${_esc(dataUrl)}" alt=""/>
<img src="${_esc(dataUrl)}"/>
<div style="margin-top:4px;font-size:0.72rem;color:var(--fg);opacity:0.85;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(s.name || '')}</div>
<button class="sig-tile-del" data-id="${_esc(s.id)}" title="Delete">×</button>
</div>
+2 -25
View File
@@ -5336,29 +5336,6 @@ function _eggRender(html) {
uiModule.scrollHistory();
}
// Delegated handlers for egg commands that used inline onclick (blocked by
// Firefox CSP script-src-attr). Dice re-roll + color copy.
if (!window._eggDelegatedBound) {
window._eggDelegatedBound = true;
document.addEventListener('click', (e) => {
const dice = e.target.closest && e.target.closest('.egg-dice');
if (dice) {
const sides = Math.min(parseInt(dice.dataset.diceSides) || 6, 1000);
dice.style.animation = 'none';
void dice.offsetHeight;
dice.textContent = Math.floor(Math.random() * sides) + 1;
dice.style.animation = 'egg-shake 0.3s ease';
return;
}
const swatch = e.target.closest && e.target.closest('.egg-color-copy');
if (swatch) {
navigator.clipboard.writeText(swatch.dataset.color || '');
swatch.style.transform = 'scale(0.9)';
setTimeout(() => { swatch.style.transform = ''; }, 150);
}
});
}
async function _cmdFlip(args, ctx) {
const isHeads = Math.random() < 0.5;
const edge = Math.random() < 0.001;
@@ -5396,7 +5373,7 @@ async function _cmdRoll(args, ctx) {
const results = Array.from({ length: count }, () => Math.floor(Math.random() * sides) + 1);
const total = results.reduce((a, b) => a + b, 0);
const dice = results.map((v, i) => {
return `<div style="min-width:42px;height:42px;border-radius:6px;border:2px solid var(--border);background:var(--panel);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--red);animation:egg-spin 0.5s ease-out ${i*0.08}s both;cursor:pointer" class="egg-dice" data-dice-sides="${sides}" title="d${sides}">${v}</div>`;
return `<div style="min-width:42px;height:42px;border-radius:6px;border:2px solid var(--border);background:var(--panel);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--red);animation:egg-spin 0.5s ease-out ${i*0.08}s both;cursor:pointer" title="d${sides}" onclick="this.style.animation='none';this.offsetHeight;var r=Math.floor(Math.random()*${sides})+1;this.textContent=r;this.style.animation='egg-shake 0.3s ease'">${v}</div>`;
}).join('');
const totalHtml = count > 1 ? `<div style="font-size:0.8em;opacity:0.5;margin-top:4px">${count}d${sides} = ${total}</div>` : '';
_eggRender(`<div style="display:flex;flex-direction:column;align-items:center;gap:4px"><div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:center">${dice}</div>${totalHtml}</div>`);
@@ -5713,7 +5690,7 @@ async function _cmdColor(args, ctx) {
const hex = args[0] || '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6,'0');
const c = hex.startsWith('#') ? hex : '#' + hex;
_eggRender(`<div style="display:flex;align-items:center;gap:12px;animation:egg-fade 0.3s ease-out">
<div style="width:48px;height:48px;border-radius:4px;border:1px solid var(--border);background:${ctx.esc(c)};cursor:pointer" class="egg-color-copy" data-color="${ctx.esc(c)}" title="Click to copy"></div>
<div style="width:48px;height:48px;border-radius:4px;border:1px solid var(--border);background:${ctx.esc(c)};cursor:pointer" title="Click to copy" onclick="navigator.clipboard.writeText('${ctx.esc(c)}');this.style.transform='scale(0.9)';setTimeout(()=>this.style.transform='',150)"></div>
<div style="display:flex;flex-direction:column;gap:2px"><code style="font-size:1.1em">${ctx.esc(c)}</code>
<span style="font-size:0.75em;opacity:0.4">click swatch to copy</span>
</div>
+1 -22
View File
@@ -34,8 +34,6 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
let tailShownLen = 0; // rendered-text length of the live tail (drives token fade)
let appendMode = null; // { codeText: Text, appendedLen } while an open fence streams
let degraded = !ENABLED; // true once we fall back to full re-render
let tailRaf = null; // pending requestAnimationFrame id for the live-tail re-render
let pendingTail = ''; // most recent tail text awaiting a rAF re-render
function start() {
contentEl.textContent = '';
@@ -81,24 +79,6 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
while (holder.firstChild) contentEl.appendChild(holder.firstChild);
}
// Coalesce the per-token tail re-render into at most one render per animation
// frame. Fast providers emit many deltas per frame; the frozen-block pass is
// cheap and stays synchronous, but the markdown re-parse + innerHTML write of
// the still-growing tail only needs to happen once per frame for smooth output.
function scheduleTailRender(tailText) {
pendingTail = tailText;
if (tailRaf !== null) return;
tailRaf = requestAnimationFrame(() => {
tailRaf = null;
// The container may have been rewritten out from under us (chat.js writes
// contentEl.innerHTML for thinking indicators and tool blocks). Skip and
// let the next update() self-heal rather than appending stale tail nodes.
if (tailMarker && tailMarker.parentNode === contentEl) {
renderTail(pendingTail);
}
});
}
// Stream the body of an unterminated code fence by appending only the new
// characters to a stable <pre><code> text node — no re-parse, no re-highlight.
function appendOpenFence(tailText, fence) {
@@ -191,7 +171,7 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
appendMode = null; // whatever was streaming is now frozen
tailShownLen = 0;
}
scheduleTailRender(fullText.slice(committedLen));
renderTail(fullText.slice(committedLen));
} catch (err) {
degraded = true;
console.error('streamingRenderer: falling back to full render', err);
@@ -207,7 +187,6 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
function finalize() {
if (degraded) return;
try {
if (tailRaf !== null) { cancelAnimationFrame(tailRaf); tailRaf = null; }
if (!started) start();
clearTail();
appendMode = null;
+1 -1
View File
@@ -3185,7 +3185,7 @@ export function openTasks(focusId, opts) {
<div class="modal-header">
<h4 style="position:relative;top:-2px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3L2 6"/><path d="M22 6l-3-3"/></svg>Tasks</h4>
<span style="flex:1"></span>
<button class="close-btn" id="tasks-close" aria-label="Close">✖</button>
<button class="close-btn" id="tasks-close">✖</button>
</div>
<div class="memory-tabs tasks-tabs" role="tablist">
<button class="memory-tab tasks-tab active" data-tab="tasks" role="tab" aria-selected="true">
-32
View File
@@ -31,33 +31,9 @@ function _targetEl(target) {
return target && target.nodeType === 1 ? target : target?.parentElement || null;
}
function _editorPanelVisible() {
if (!document.body || !document.body.classList.contains('ge-editor-open')) return false;
const sel = '.ge-adj-popup, .ge-fx-popup, .ge-fx-menu, .ge-transform-popup, .ge-filter-overlay, .ge-history-panel';
return [...document.querySelectorAll(sel)].some((candidate) => {
if (!document.contains(candidate)) return false;
const style = getComputedStyle(candidate);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = candidate.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
}
function _positionToastForOpenPanel(el) {
if (!el) return;
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
el.classList.remove('toast-editor-bottom');
// In the editor, a floating panel (Adjust/Develop, FX, Transform, filter
// modal, History) is anchored to the top/right; drop the toast to the
// bottom-centre so it can never cover a control (EVAL: "never over an
// active panel"). On phones the CSS already anchors it at the bottom.
if (_editorPanelVisible()) {
el.style.left = '';
el.style.right = '';
el.style.maxWidth = '';
el.classList.add('toast-editor-bottom');
return;
}
const panel = [...document.querySelectorAll('.notes-pane')]
.filter((candidate) => {
if (!document.contains(candidate)) return false;
@@ -382,14 +358,6 @@ export function showToast(msg, durationOrOpts) {
} else if (typeof durationOrOpts === 'number') {
duration = durationOrOpts;
}
// While the editor is open, cap every toast so it auto-dismisses within
// ~2.5s and can never linger over the canvas or a panel (EVAL: "auto-dismiss
// in 2.5s"). Longer informational durations (persist warnings etc.) are
// still readable but do not pile up.
if (typeof document !== 'undefined' && document.body
&& document.body.classList.contains('ge-editor-open')) {
duration = Math.min(duration, 2500);
}
if (aiReplyProgress) toastEl.classList.add('toast-ai-reply-progress');
if (aiReplyResult) toastEl.classList.add('toast-ai-reply-result');
if (toastClass) toastEl.classList.add(toastClass);
File diff suppressed because one or more lines are too long
-8
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -6,7 +6,6 @@
<title>Odysseus — Login</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="manifest" href="static/manifest.json">
<link rel="apple-touch-icon" href="static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}">
(function(){
function computeAppBasePath() {
+1 -6
View File
@@ -7,10 +7,5 @@
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_color": "#282c34",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
"theme_color": "#282c34"
}
+1 -5
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v658-publication-assets-restore';
const CACHE_NAME = 'odysseus-v657-publication-plan-b';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -134,10 +134,6 @@ const PRECACHE = [
'/static/lib/katex/katex.min.js',
'/static/lib/katex/katex.min.css',
...KATEX_FONTS,
'/static/manifest.json',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
'/static/icons/icon-maskable-512.png',
];
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
+7 -41
View File
@@ -14,57 +14,23 @@ const { extractThemeBootstrap } = require('./helpers/theme_bootstrap.cjs');
page.on('request', req => { if (/\.(webm|mp4)(?:$|\?)/.test(req.url())) mediaRequests.push(req.url()); });
page.on('pageerror', e => errors.push(e.message));
await page.goto(origin + '/website/index.html');
// Landing page contains expected preview panels, preview videos, and background video
assert.equal(await page.locator('.preview-panel').count(), 8);
assert.equal(await page.locator('section#previews video').count(), 7);
assert.equal(await page.locator('section#how video.sec-bg').count(), 1);
assert.equal(await page.locator('section#how .sec-bg-tint').count(), 1);
// Expected video source paths exist and return 200
const expectedVideos = [
'chat.webm',
'research.webm',
'compare.webm',
'document.webm',
'notes.webm',
'gallery.webm',
'theme.webm',
'bg.webm',
];
const videoSources = await page.locator('video source').evaluateAll(els => els.map(s => s.getAttribute('src')));
assert.deepEqual(videoSources.sort(), expectedVideos.slice().sort());
for (const src of expectedVideos) {
const videoRes = await page.request.get(origin + '/website/' + src);
assert.equal(videoRes.status(), 200, `Video source ${src} should return 200`);
}
// Feature and video layout does not overflow at desktop or mobile widths
assert.equal(await page.locator('video, .preview-panel, .sec-bg-tint').count(), 0);
assert.equal(await page.locator('.feature-description').count(), 8);
for (const description of await page.locator('.feature-description .desc').allTextContents()) assert(description.trim());
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 900 });
assert(await page.locator('.preview-panel').first().isVisible());
assert(await page.locator('.feature-description').first().isVisible());
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
}
assert.deepEqual(mediaRequests, []);
assert.deepEqual(errors, []);
// Manifest has valid icon entries that return successfully
const manifest = await (await page.request.get(origin + '/static/manifest.json')).json();
assert.equal(manifest.display, 'standalone');
assert(Array.isArray(manifest.icons) && manifest.icons.length === 3);
for (const icon of manifest.icons) {
assert(icon.src, 'Manifest icon entry must have src');
const iconUrl = new URL(icon.src, origin + '/static/manifest.json').href;
const iconRes = await page.request.get(iconUrl);
assert.equal(iconRes.status(), 200, `Manifest icon ${icon.src} should return 200`);
}
// Apple touch icon references in app HTML point to real files
assert(!manifest.icons);
for (const filename of ['index.html', 'login.html']) {
const html = await (await page.request.get(origin + '/static/' + filename)).text();
const match = html.match(/<link[^>]+rel=["']apple-touch-icon["'][^>]+href=["']([^"']+)["']/);
assert(match, `missing apple-touch-icon in ${filename}`);
const iconUrl = new URL(match[1], origin + '/').href;
const res = await page.request.get(iconUrl);
assert.equal(res.status(), 200, `Apple touch icon in ${filename} should return 200`);
assert(!/<link[^>]+rel=["']apple-touch-icon/.test(html));
}
// A small host DOM runs the actual export function and markdown renderer.
+1 -38
View File
@@ -176,41 +176,6 @@ def test_native_file_tools_hide_control_plane_hardlink_alias(tmp_path, monkeypat
assert ":1:LIVE_ADMIN_SESSION" not in grep_result["output"]
def test_native_file_tools_take_one_control_plane_snapshot_per_scan(tmp_path, monkeypatch):
"""The deny check must not rebuild the control-plane snapshot per entry.
Each rebuild stats every protected state file, so a per-entry rebuild made
a 5,000-file grep about seven times slower and timed out near 20k files.
"""
resources = importlib.import_module("src.agent_runtime.resources")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
for index in range(3):
directory = workspace / f"d{index}"
directory.mkdir(parents=True)
for item in range(20):
(directory / f"f{item}.txt").write_text("NEEDLE\n" if item == 0 else "x\n")
snapshots = []
original = resources._control_plane_snapshot
def counting_snapshot():
snapshots.append(1)
return original()
monkeypatch.setattr(resources, "_control_plane_snapshot", counting_snapshot)
for tool, args, expected in (
(LsTool(), {"path": str(workspace / "d0")}, "f19.txt"),
(GlobTool(), {"pattern": "**/*.txt", "path": str(workspace)}, "f19.txt"),
(GrepTool(), {"pattern": "NEEDLE", "path": str(workspace)}, "f0.txt:1:NEEDLE"),
):
snapshots.clear()
result = asyncio.run(tool.execute(json.dumps(args), {}))
assert expected in result.get("output", ""), result
# One fresh check resolves the search root; the walk shares one more.
assert len(snapshots) <= 2, (type(tool).__name__, len(snapshots))
def test_blocks_app_state_on_a_case_insensitive_filesystem():
"""On default macOS a case-variant path opens the same file, and realpath
does not canonicalise case there the way it does on Windows.
@@ -366,9 +331,7 @@ def test_allows_files_in_the_agent_workspace():
(PERSONAL_UPLOADS_DIR,
"indexed into personal docs by routes/personal_routes.py, and listed as "
"an absolute path by manage_rag"),
# The directories live under a per-worker data dir, so they cannot be test ids:
# xdist requires every worker to collect the same names.
], ids=["uploads", "mail_attachments", "personal", "personal_uploads"])
])
def test_allows_user_content_the_app_hands_to_the_model(directory, why):
"""Carving these out is not convenience. The app gives the model these
paths and tells it to read them, so denying them breaks the feature."""
+98
View File
@@ -0,0 +1,98 @@
"""Invalid truncation requests must leave saved chat history intact."""
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from tests.helpers.database import isolated_session_database
@pytest.fixture
def history_client(tmp_path, monkeypatch):
from core.models import ChatMessage
from routes.history import history_routes
from routes import session_routes
with isolated_session_database(tmp_path) as (manager, database):
monkeypatch.setattr(history_routes, "SessionLocal", database.SessionLocal)
monkeypatch.setattr(session_routes, "SessionLocal", database.SessionLocal)
monkeypatch.setenv("AUTH_ENABLED", "true")
manager.create_session("truncate-test", "Test", "http://example.test", "test", owner="alice")
for text in ("first", "second", "third"):
manager.add_message("truncate-test", ChatMessage("user", text))
app = FastAPI()
app.state.auth_manager = SimpleNamespace(is_configured=True)
@app.middleware("http")
async def owner(request, call_next):
request.state.current_user = request.headers.get("X-Test-Owner", "alice")
return await call_next(request)
app.include_router(history_routes.setup_history_routes(manager))
with TestClient(app, raise_server_exceptions=False) as client:
yield client, manager, database
def saved_messages(database):
with database.SessionLocal() as db:
return [row.content for row in db.query(database.ChatMessage).filter(
database.ChatMessage.session_id == "truncate-test"
).order_by(database.ChatMessage.timestamp).all()]
@pytest.mark.parametrize("payload", [
{}, {"unrelated": 1}, {"before_msg_id": ""},
{"keep_count": None}, {"keep_count": False}, {"keep_count": True},
{"keep_count": 1.9}, {"keep_count": -1}, {"keep_count": "bad"},
{"keep_count": []}, {"keep_count": {}},
{"before_msg_id": True}, {"before_msg_id": ["message"]},
{"before_msg_id": False}, {"before_msg_id": 0}, {"message_id": 0},
[], None, "two", 2, False,
])
def test_invalid_target_preserves_history(history_client, payload):
client, manager, database = history_client
import json
response = client.post("/api/session/truncate-test/truncate", content=json.dumps(payload), headers={"Content-Type": "application/json"})
assert response.status_code == 400, response.text
assert saved_messages(database) == ["first", "second", "third"]
assert [m.content for m in manager.get_session("truncate-test").history] == ["first", "second", "third"]
def test_malformed_json_preserves_history(history_client):
client, _, database = history_client
response = client.post("/api/session/truncate-test/truncate", content="{", headers={"Content-Type": "application/json"})
assert response.status_code == 400, response.text
assert saved_messages(database) == ["first", "second", "third"]
@pytest.mark.parametrize("count,expected", [(0, []), (2, ["first", "second"]), (9, ["first", "second", "third"]), ("2", ["first", "second"])])
def test_explicit_count_remains_supported(history_client, count, expected):
client, _, database = history_client
response = client.post("/api/session/truncate-test/truncate", json={"keep_count": count})
assert response.status_code == 200, response.text
assert saved_messages(database) == expected
@pytest.mark.parametrize("field", ["before_msg_id", "message_id"])
def test_message_id_boundary_remains_supported(history_client, field):
client, _, database = history_client
with database.SessionLocal() as db:
message_id = db.query(database.ChatMessage).filter(database.ChatMessage.content == "second").one().id
response = client.post("/api/session/truncate-test/truncate", json={field: message_id})
assert response.status_code == 200, response.text
assert saved_messages(database) == ["first"]
def test_missing_message_id_does_not_clear_history(history_client):
client, _, database = history_client
response = client.post("/api/session/truncate-test/truncate", json={"before_msg_id": "missing"})
assert response.status_code == 404
assert saved_messages(database) == ["first", "second", "third"]
def test_owner_gate_still_precedes_validation(history_client):
client, _, database = history_client
response = client.post("/api/session/truncate-test/truncate", json={}, headers={"X-Test-Owner": "bob"})
assert response.status_code == 404
assert saved_messages(database) == ["first", "second", "third"]
-125
View File
@@ -1,125 +0,0 @@
"""Background note reminders retry failed primary delivery without losing receipts."""
import datetime
import json
from types import SimpleNamespace
import httpx
import pytest
from tests.helpers.database import disposable_database
@pytest.fixture
def reminder_scan(tmp_path, monkeypatch):
from core import database
from routes import note_routes
from src import builtin_actions, integrations, settings
instant = datetime.datetime(2026, 10, 7, 12, tzinfo=datetime.timezone.utc)
class Clock(datetime.datetime):
@classmethod
def now(cls, tz=None):
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
calls, notices = [], []
statuses = [503, 200]
channel = {"reminder_channel": "webhook", "reminder_webhook_integration_id": "audit", "reminder_webhook_payload_template": '{"message":"{{message}}"}'}
def deliver(request):
calls.append(request)
return httpx.Response(statuses.pop(0))
real_client = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: real_client(transport=httpx.MockTransport(deliver), **kw))
monkeypatch.setattr(settings, "load_settings", lambda: channel)
monkeypatch.setattr(integrations, "load_integrations", lambda: [{"id": "audit", "preset": "ntfy", "base_url": "https://reminder.example.test", "enabled": True}])
monkeypatch.setattr(note_routes, "_scheduler_ref", SimpleNamespace(add_notification=lambda **kw: notices.append(kw)))
monkeypatch.setattr(note_routes, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(builtin_actions, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(datetime, "datetime", Clock)
monkeypatch.setattr("src.url_safety.check_outbound_url", lambda *a, **kw: (True, ""))
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, "SessionLocal", factory)
with factory() as db:
db.add(database.Note(id="note-1", owner="alice", title="Due note", due_date=instant.isoformat()))
db.commit()
yield SimpleNamespace(
scan=lambda: builtin_actions.action_ping_notes("alice"),
path=tmp_path / "note_pings_alice.json", calls=calls, notices=notices,
statuses=statuses, channel=channel, instant=instant, factory=factory,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("channel", ["webhook", "ntfy"])
async def test_failed_primary_is_retried_and_receipt_keeps_channel(reminder_scan, channel):
from src.builtin_actions import TaskNoop
scan = reminder_scan
scan.channel["reminder_channel"] = channel
with pytest.raises(TaskNoop):
await scan.scan()
assert len(scan.calls) == 1
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "browser"
message, ok = await scan.scan()
assert ok is True and "Pinged 1" in message
assert len(scan.calls) == 2
assert len(scan.notices) == 1
assert json.loads(scan.path.read_text())["note-1"]["channel"] == channel
with pytest.raises(TaskNoop):
await scan.scan()
assert len(scan.calls) == 2
@pytest.mark.asyncio
async def test_legacy_browser_receipt_allows_external_retry(reminder_scan):
scan = reminder_scan
scan.path.write_text(json.dumps({"note-1": scan.instant.isoformat()}))
scan.statuses[:] = [200]
message, ok = await scan.scan()
assert ok is True and "Pinged 1" in message
assert len(scan.calls) == 1 and scan.notices == []
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "webhook"
@pytest.mark.asyncio
async def test_browser_delivery_is_deduped_and_typed(reminder_scan):
from src.builtin_actions import TaskNoop
scan = reminder_scan
scan.channel["reminder_channel"] = "browser"
_, ok = await scan.scan()
assert ok is True
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "browser"
with pytest.raises(TaskNoop):
await scan.scan()
assert len(scan.notices) == 1 and scan.calls == []
@pytest.mark.asyncio
async def test_total_delivery_failure_does_not_checkpoint(reminder_scan, monkeypatch):
from routes import note_routes
from src.builtin_actions import TaskNoop
scan = reminder_scan
monkeypatch.setattr(note_routes, "_scheduler_ref", None)
with pytest.raises(TaskNoop):
await scan.scan()
assert "note-1" not in json.loads(scan.path.read_text())
_, ok = await scan.scan()
assert ok is True and len(scan.calls) == 2
@pytest.mark.asyncio
async def test_pruning_retains_dispatcher_receipt_and_other_due_note(reminder_scan):
from core import database
scan = reminder_scan
with scan.factory() as db:
db.add(database.Note(id="later", owner="alice", title="Later", due_date=(scan.instant + datetime.timedelta(days=1)).isoformat()))
db.commit()
scan.path.write_text(json.dumps({"gone": "old", "later": {"at": scan.instant.isoformat(), "channel": "email"}}))
scan.statuses[:] = [200]
_, ok = await scan.scan()
assert ok is True
cache = json.loads(scan.path.read_text())
assert set(cache) == {"note-1", "later"}
assert cache["note-1"]["channel"] == "webhook"
assert cache["later"]["channel"] == "email"
+11 -45
View File
@@ -30,49 +30,23 @@ def test_retained_artifacts_match_distributed_provenance_and_notices():
def test_removed_resource_ledger_has_no_runtime_references():
ledger = (ROOT / 'PUBLICATION_ASSET_DECISIONS.md').read_text()
san_entries = re.findall(r'^- SAN-\d+: `([^`]+)`$', ledger, re.M)
assert len(san_entries) == 21
restore_entries = re.findall(r'^- RESTORE-\d+: `([^`]+)` \(SHA-256: `([a-f0-9]{64})`\)', ledger, re.M)
assert len(restore_entries) == 14
restored_paths = {path for path, _ in restore_entries}
for path, expected_sha in restore_entries:
target = ROOT / path
assert target.is_file(), f"restored asset missing on disk: {path}"
actual_sha = hashlib.sha256(target.read_bytes()).hexdigest()
assert actual_sha == expected_sha, f"sha256 mismatch for {path}"
still_omitted = [path for path in san_entries if path not in restored_paths]
assert len(still_omitted) == 7
expected_omitted_names = {
'GohuFont.ttf',
'html2pdf.bundle.min.js',
'qrcode.min.js',
'ollama-mark.png',
'ollama-mark-crop.png',
'sglang-logo.png',
'sglang-mark.png',
}
assert {Path(p).name for p in still_omitted} == expected_omitted_names
# Inspect tracked text for references to still-omitted resources.
removed = re.findall(r'^- SAN-\d+: `([^`]+)`$', ledger, re.M)
assert len(removed) == 21
# Inspect tracked text, including files omitted by parity-audit exclusions.
tracked = subprocess.check_output(['git', 'ls-files', '-z'], cwd=ROOT).decode().split('\0')
for path in still_omitted:
assert not (ROOT / path).exists(), f"omitted asset must not exist: {path}"
filename = Path(path).name
for path in removed:
assert not (ROOT / path).exists()
for name in tracked:
if name in {"PUBLICATION_ASSET_DECISIONS.md", "tests/test_publication_plan_b.py"}:
continue # Historical ledger and test assertions are intentionally non-resource references.
if name == "PUBLICATION_ASSET_DECISIONS.md":
continue # Historical ledger entries are intentionally non-resource references.
file = ROOT / name
if not file.is_file():
continue
try:
content = file.read_text(encoding="utf-8")
content = file.read_text()
except UnicodeError:
continue
assert filename not in content, (path, name)
assert Path(path).name not in content, (path, name)
def test_cold_catalog_and_refresh_use_mutable_data(monkeypatch, tmp_path):
@@ -139,20 +113,12 @@ def test_distribution_paths_include_all_notices():
ignore = (ROOT / '.dockerignore').read_text()
assert '!ACKNOWLEDGMENTS.md' in ignore
manifest = json.loads((ROOT / 'static/manifest.json').read_text())
assert manifest.get('icons')
assert not manifest.get('icons')
assert manifest['display'] == 'standalone'
expected_icons = [
{"src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable"},
{"src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"},
{"src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"},
]
assert manifest['icons'] == expected_icons
for icon in manifest['icons']:
assert (ROOT / 'static' / icon['src']).is_file()
def test_browser_publication_behaviors():
"""Real Chromium DOM: restored website media, saved fonts, escaped print/math."""
"""Real Chromium DOM: text-only website, saved fonts, escaped print/math."""
if not shutil.which('node'):
pytest.skip('Node is required')
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=ROOT, capture_output=True)
+4 -3
View File
@@ -1,7 +1,8 @@
"""Regression guard for the README title presentation.
The original ASCII-art banner needed a code fence to preserve whitespace.
Keep the wordmark title identity and the historical ASCII-fencing guard.
Publication now opens with a centered text heading after omitting the wordmark
artwork. Keep the title identity and the historical ASCII-fencing guard.
"""
from pathlib import Path
@@ -18,9 +19,9 @@ def _fenced_segments(text: str):
return parts[1::2]
def test_readme_opens_with_wordmark_title():
def test_readme_opens_with_text_title():
head = "\n".join(README.read_text(encoding="utf-8").splitlines()[:15])
assert 'alt="Odysseus"' in head, "README must open with the Odysseus wordmark image"
assert head.startswith('<h1 align="center">Odysseus</h1>'), "README must open with the Odysseus text title"
def test_reintroduced_ascii_banner_stays_fenced():
-42
View File
@@ -222,45 +222,3 @@ def test_untrusted_validation_detail_cannot_masquerade_as_curated_guidance(detai
assert "TAKEOVER_SECRET" not in public
assert "/srv/private" not in public
assert "PRIVATE_TOKEN" not in public
@pytest.mark.asyncio
@pytest.mark.parametrize("tool, arguments, hint", [
# Authored by normalized_native_function_argument_error.
("pdf_extract", {"url": "Attention Is All You Need"},
"pdf_extract requires a public http(s) PDF URL"),
# Rebuilt from the schema, not from the caught ValidationError.
("manage_notes", {"action": "explode"},
"Argument 'action' must be one of: list, search, view"),
("python", {},
"Missing required argument: 'code'."),
])
async def test_preview_returns_validator_guidance_to_the_model(monkeypatch, tool, arguments, hint):
module = _preview_provider(monkeypatch, [
{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call-1", "function": {
"name": tool, "arguments": json.dumps(arguments),
}}]}}]},
{"choices": [{"delta": {"content": "The requested call failed."}}]},
] * 8)
async def execute(*args, **kwargs):
raise AssertionError("an invalid call must not execute")
monkeypatch.setattr(module, "execute_tool_block", execute)
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s["function"]["name"] == tool)
chunks = await _client_chunks(_preview_generator(module, [schema]), False)
tool_event = next(e for e in _events(chunks) if e.get("type") == "tool_output")
assert tool_event["error_category"] == "invalid_tool_arguments"
assert hint in tool_event["output"]
assert "could not be validated" not in tool_event["output"]
def test_schema_hint_ignores_the_caught_exception_text():
from src.clean_agent_preview import _schema_argument_hint
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s["function"]["name"] == "manage_notes")
# A ValidationError raised for arguments that are actually valid yields no
# hint, so the caller falls back to the curated schema message.
assert _schema_argument_hint({"action": "list"}, schema["function"]["parameters"]) == ""
hint = _schema_argument_hint({"action": SENSITIVE}, schema["function"]["parameters"])
assert "must be one of" in hint
assert "TAKEOVER_SECRET" not in hint
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20 -195
View File
@@ -273,87 +273,12 @@
.shot .frame-dots { position: absolute; top: 10px; left: 12px; display: flex; gap: 5px; }
.shot .frame-dots i { width: 8px; height: 8px; border-radius: 50%; background: #39414d; display: inline-block; }
/* Previews: expanding hover carousel that plays a video on hover/tap */
.previews { display: flex; align-items: center; gap: 12px; height: 480px; max-width: 1000px; margin: 36px auto 0; }
.preview-panel {
position: relative; flex: 1 1 0; min-width: 0; height: 360px; overflow: hidden;
border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer;
background: linear-gradient(180deg, var(--panel), var(--panel2));
transition: flex-grow .5s cubic-bezier(.2,.7,.2,1), height .5s cubic-bezier(.2,.7,.2,1), border-color .25s ease;
}
.previews:hover .preview-panel { flex-grow: 0.55; height: 300px; }
.preview-panel:hover, .preview-panel:focus-visible, .preview-panel.is-active { flex-grow: 3.4 !important; height: 480px !important; border-color: var(--accent); }
.preview-panel .ph {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 10px;
color: var(--muted); font-size: 12.5px; opacity: 0.7; text-align: center; padding: 8px;
}
.preview-panel video {
position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
z-index: 1; opacity: 0; transition: opacity .3s ease; background: transparent;
}
.preview-panel.has-video video { opacity: 1; }
/* These clips have their action on the left, so show the left edge instead of
the centered crop. */
.preview-panel:has(source[src="document.webm"]) video,
.preview-panel:has(source[src="notes.webm"]) video { object-position: right center; }
.preview-panel .label {
position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; padding: 14px 16px;
background: linear-gradient(0deg, rgba(0,0,0,0.82), transparent);
color: var(--heading);
display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
}
.preview-panel .label .t { display: flex; align-items: center; gap: 8px; white-space: nowrap; font-weight: 700; font-size: 14px; }
.preview-panel .label .ico { color: var(--accent); flex-shrink: 0; }
.preview-panel .label .desc {
font-weight: 400; font-size: 12.5px; line-height: 1.35; color: rgba(255,255,255,0.82);
white-space: normal; max-height: 0; opacity: 0; overflow: hidden;
transition: max-height .4s ease, opacity .4s ease;
}
.preview-panel:hover .label .desc, .preview-panel:focus-visible .label .desc, .preview-panel.is-active .label .desc { max-height: 64px; opacity: 1; }
@media (max-width: 760px) {
.previews { flex-direction: column; height: auto; touch-action: pan-y; }
.preview-panel { height: 190px; flex: none; width: 100%; }
.preview-panel.is-active { height: 280px !important; }
.previews:hover .preview-panel, .preview-panel:hover { flex: none !important; }
.preview-panel .label .desc { max-height: 64px; opacity: 1; }
}
/* Fullscreen video background for a section: treated as an ambient, cinematic
backdrop (soft blur + slow drift) so it sets a mood without fighting the copy. */
.has-bg-video { position: relative; overflow: hidden; }
.has-bg-video .sec-bg {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; z-index: 0; pointer-events: none;
/* blur softens the busy frame; the extra scale hides the blurred edges */
filter: blur(4px) saturate(1.08) brightness(0.92);
transform: scale(1.12);
transform-origin: 55% 45%;
animation: bg-drift 36s ease-in-out infinite alternate;
will-change: transform;
}
@keyframes bg-drift {
from { transform: scale(1.12) translate(0, 0); }
to { transform: scale(1.2) translate(-2.5%, -1.5%); }
}
.has-bg-video .sec-bg-tint {
position: absolute; inset: 0; z-index: 1; pointer-events: none;
background:
radial-gradient(900px 520px at 78% 18%, rgba(224,108,117,0.16), transparent 60%),
radial-gradient(760px 520px at 8% 88%, rgba(53,90,102,0.30), transparent 58%),
linear-gradient(180deg, rgba(17,17,17,0.86), rgba(17,17,17,0.62) 42%, rgba(17,17,17,0.92)),
radial-gradient(1200px 680px at 50% 46%, rgba(17,17,17,0.18), rgba(17,17,17,0.74));
}
.has-bg-video .wrap { position: relative; z-index: 2; }
/* Lift the copy off the moving backdrop. */
.has-bg-video .eyebrow,
.has-bg-video .h { text-shadow: 0 2px 22px rgba(0,0,0,0.7); }
.has-bg-video .sub { color: #b9e6f4; text-shadow: 0 1px 14px rgba(0,0,0,0.75); }
.hero.has-bg-video h1, .hero.has-bg-video .wordmark,
.hero.has-bg-video .lede, .hero.has-bg-video .slogan { text-shadow: 0 2px 22px rgba(0,0,0,0.72); }
@media (prefers-reduced-motion: reduce) {
.has-bg-video .sec-bg { animation: none; transform: scale(1.12); }
}
/* Feature descriptions */
.feature-descriptions { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-top: 36px; }
.feature-description { padding: 20px; border: 1px solid var(--border); border-radius: 12px; background: var(--panel); }
.feature-description .t { display: flex; align-items: center; gap: 8px; font-weight: 700; }
.feature-description .ico { color: var(--accent); }
.feature-description .desc { display: block; margin-top: 8px; color: var(--muted); line-height: 1.6; }
/* Get started */
.start {
@@ -598,62 +523,28 @@
</div>
</section>
<!-- PREVIEWS: hover/tap to expand + play -->
<section id="previews">
<!-- FEATURE DESCRIPTIONS -->
<section id="features-overview">
<div class="wrap">
<div class="center">
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>See it in action</div>
<h2 class="h">Hover or tap to take a closer look</h2>
<p class="sub center">Each panel expands and plays its preview when you hover or tap it. Swipe on mobile to move through them.</p>
<h2 class="h">Explore the workspace</h2>
<p class="sub center">Tools for local AI and everyday work.</p>
</div>
<div class="previews">
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span>[ Chat &amp; Agents ]</span></div>
<video muted loop playsinline preload="none"><source src="chat.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Chat &amp; Agents</span><span class="desc">Talk to any local model, or give it tools and let the agent run.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg><span>[ Cookbook ]</span></div>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>Cookbook</span><span class="desc">Download, serve, and manage local models across your machines.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></svg><span>[ Deep Research ]</span></div>
<video muted loop playsinline preload="none"><source src="research.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></svg>Deep Research</span><span class="desc">Ask once: it searches, reads sources, and writes back a cited report.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1"/><rect x="14" y="4" width="7" height="16" rx="1"/></svg><span>[ Compare ]</span></div>
<video muted loop playsinline preload="none"><source src="compare.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1"/><rect x="14" y="4" width="7" height="16" rx="1"/></svg>Compare</span><span class="desc">Send one prompt to many models at once and watch them answer side by side.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8M16 17H8M10 9H8"/></svg><span>[ Documents ]</span></div>
<video muted loop playsinline preload="none"><source src="document.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8M16 17H8M10 9H8"/></svg>Documents</span><span class="desc">A document editor that puts you first - work on what you want, with AI help when you want it.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8M13 18h8"/></svg><span>[ Notes &amp; Tasks ]</span></div>
<video muted loop playsinline preload="none"><source src="notes.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8M13 18h8"/></svg>Notes &amp; Tasks</span><span class="desc">Capture notes and to-dos, or let scheduled agents work and brief you after.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21"/></svg><span>[ Image Gallery ]</span></div>
<video muted loop playsinline preload="none"><source src="gallery.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21"/></svg>Image Gallery</span><span class="desc">Generate, edit, remove backgrounds, and inpaint in your own gallery.</span></div>
</div>
<div class="preview-panel" tabindex="0">
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.7 6.3 8.4a8 8 0 1 0 11.4 0z"/></svg><span>[ Themes ]</span></div>
<video muted loop playsinline preload="none"><source src="theme.webm" type="video/webm"></video>
<div class="label"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.7 6.3 8.4a8 8 0 1 0 11.4 0z"/></svg>Themes</span><span class="desc">Restyle and make it yours - edit your own, or ask the agent to make one.</span></div>
</div>
<div class="feature-descriptions">
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Chat &amp; Agents</span><span class="desc">Talk to any local model, or give it tools and let the agent run.</span></div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>Cookbook</span><span class="desc">Download, serve, and manage local models across your machines.</span></div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></svg>Deep Research</span><span class="desc">Ask once: it searches, reads sources, and writes back a cited report.</span></div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1"/><rect x="14" y="4" width="7" height="16" rx="1"/></svg>Compare</span><span class="desc">Send one prompt to many models at once and watch them answer side by side.</span></div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8M16 17H8M10 9H8"/></svg>Documents</span><span class="desc">A document editor that puts you first — work on what you want, with AI help when you want it.</span></div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 2 2 4-4"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8M13 18h8"/></svg>Notes &amp; Tasks</span><span class="desc">Capture notes and to-dos, or let scheduled agents work and brief you after.</span></div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21"/></svg>Image Gallery</span><span class="desc">Generate, edit, remove backgrounds, and inpaint in your own gallery.</span></div>
<div class="feature-description"><span class="t"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.7 6.3 8.4a8 8 0 1 0 11.4 0z"/></svg>Themes</span><span class="desc">Restyle and make it yours — edit your own, or ask the agent to make one.</span></div>
</div>
</div>
</section>
<!-- HOW IT STARTED -->
<section id="how" class="has-bg-video">
<video class="sec-bg" autoplay muted loop playsinline preload="auto"><source src="bg.webm" type="video/webm"></video>
<div class="sec-bg-tint"></div>
<section id="how">
<div class="wrap">
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1z"/></svg>How it actually started</div>
<h2 class="h">Uncompromised local LLM experience.</h2>
@@ -790,72 +681,6 @@
}
})();
// Previews: hovering/tapping a panel expands it (CSS) and plays its video; the
// video only becomes visible once it actually starts playing, so missing
// files just leave the labeled placeholder.
(function () {
var panels = [].slice.call(document.querySelectorAll('.preview-panel'));
if (!panels.length) return;
var active = -1;
function playPanel(p) {
var v = p.querySelector('video');
if (!v) return;
var pr = v.play();
if (pr && pr.catch) pr.catch(function () {});
}
function pausePanel(p) {
var v = p.querySelector('video');
if (v) v.pause();
}
function setActive(i, shouldPlay) {
active = (i + panels.length) % panels.length;
panels.forEach(function (panel, k) {
var on = k === active;
panel.classList.toggle('is-active', on);
panel.setAttribute('aria-expanded', on ? 'true' : 'false');
if (!on) pausePanel(panel);
});
if (shouldPlay !== false) playPanel(panels[active]);
}
panels.forEach(function (p, i) {
var v = p.querySelector('video');
if (v) {
v.addEventListener('playing', function () { p.classList.add('has-video'); });
v.addEventListener('pause', function () { /* keep last frame */ });
}
p.setAttribute('aria-expanded', 'false');
p.addEventListener('mouseenter', function () { setActive(i); });
p.addEventListener('focus', function () { setActive(i); });
p.addEventListener('mouseleave', function () {
if (!window.matchMedia || !window.matchMedia('(hover: none)').matches) {
p.classList.remove('is-active');
p.setAttribute('aria-expanded', 'false');
pausePanel(p);
}
});
p.addEventListener('blur', function () { pausePanel(p); });
p.addEventListener('click', function () { setActive(i); });
});
var strip = document.querySelector('.previews');
var sx = null, sy = null;
if (strip) {
strip.addEventListener('touchstart', function (e) {
if (!e.touches.length) return;
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}, { passive: true });
strip.addEventListener('touchend', function (e) {
if (sx === null || sy === null || !e.changedTouches.length) return;
var dx = e.changedTouches[0].clientX - sx;
var dy = e.changedTouches[0].clientY - sy;
if (Math.abs(dx) > 42 && Math.abs(dx) > Math.abs(dy) * 1.25) {
setActive((active < 0 ? 0 : active) + (dx < 0 ? 1 : -1));
}
sx = sy = null;
}, { passive: true });
}
})();
// Domino reveal: fade/slide each section in as it scrolls into view.
(function () {
var els = document.querySelectorAll('.hero, section');
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -762,7 +762,7 @@ src/ llm_core, agent_loop, agent_tools, chat_processor, search/
routes/ chat, session, document, memory, model … endpoints
services/ docs, memory, search, hwfit (Cookbook) …
static/ index.html + app.js + style.css + js/ (modular front-end)
website/ landing page (index.html) + preview clips
website/ text-only landing page (index.html)
```
## Data
Binary file not shown.