Compare commits

...
Author SHA1 Message Date
pewdiepie-archdaemonandClaude Opus 5.5 8fda203d28 feat(ui): chat frontend, model picker, streaming, admin, a11y
Preview/UI work: chat renderer, streaming/think-trace, model picker
(instant-open/refresh-backoff), admin panel, a11y, gallery landing.

Source: vendor/photocraft (ad1cff44) static files, 3-way merged onto
upstream/dev (a8c147b2). Resolution policy in pr_work/D_conflicts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:33 +00:00
Robert Vind-Gardoș a8c147b238 fix(reminders): retry failed note delivery through the configured channel (#6566) 2026-10-07 15:38:41 +02:00
Léo ec5bcd2b9a perf(tools): take one control-plane snapshot per grep/glob/ls scan (#6551)
The #6503 merge added _control_plane_path() to _is_denied_tool_path(),
which grep, glob and ls call per enumerated entry. Without a snapshot
argument every call rebuilt _control_plane_snapshot() and stat'ed every
protected state file again. A 5,000-file grep went from 0.75 s to ~7 s,
and around 20k files it hits the grep timeout.

Thread an optional snapshot through _is_denied_tool_path() and
_can_traverse_tool_path(), and build it once per scan, the way
process_resources already does for its launch-boundary walk. Root
resolution and bound-resource resolution still observe fresh state.
2026-10-07 12:57:40 +01:00
Léo 137daab16e fix(preview): return validator-authored tool errors to the model (#6554)
The compact preview reduces every pre-execution tool error to a fixed
allowlist so exception text cannot reach the client. Messages our own
validators build per call are not on that list, so the model got "The
tool call could not be validated..." instead of the pdf_extract path
hint, the email uid provenance error, the artifact-Python target
message, or which schema argument was wrong. That is the default path
for the tool-profile models, Qwen3.5-9B included.

Each validator site now records its message in a per-call
validator_error before raising, and only that text is returned
verbatim. A schema failure gets a hint rebuilt from the offered schema
and the call's own arguments (missing property, expected type, allowed
values), never from the caught ValidationError. Everything else,
including all execution failures, still goes through
_public_preview_tool_error.
2026-10-07 12:52:39 +01:00
Léo 41cace1337 test(confinement): give user-content carve-out cases stable ids (#6555)
test_allows_user_content_the_app_hands_to_the_model is parametrized on
UPLOAD_DIR and friends, which sit under the per-worker data dir. pytest
used those paths as test ids, so every xdist worker collected different
names and `pytest -n 4` (documented in tests/README.md) aborted at
collection. CI shards with --shard and never saw it.
2026-10-07 12:51:58 +01:00
Léo 10377f0c95 ci: cut per-shard setup time in the pytest matrix (#6557)
Each of the four pytest shards spends ~84 s on setup before pytest
starts, and repeats all of it: pip resolves and installs ~108 packages
(~24 s, even with the pip cache hit), Playwright downloads Chromium and
the headless shell with no cache (~11 s), and three separate
apt-get update calls run.

- Install requirements with uv (astral-sh/setup-uv, pinned) and cache
  its downloads keyed on requirements.txt. uv resolves the same 108
  pins as pip for this requirements.txt.
- Cache ~/.cache/ms-playwright keyed on package-lock.json so a hit
  skips the browser downloads; --with-deps still installs system libs.
- Install FFmpeg and bubblewrap in one apt pass. The containment step
  keeps its bwrap probes unchanged.
- Add --durations=25 so the slowest tests per shard are visible; the
  shard planner only weights the slow marker, and shard times vary
  from 154 s to 296 s within one run.
2026-10-07 13:07:40 +02:00
Alexandre Teixeira 2c8e00ca05 fix(publication): restore project-origin branding and demo assets (#6561)
* chore(publication): restore cleared first-party assets

* fix(branding): restore publication asset integrations

* fix(publication): tighten restoration provenance
2026-10-07 02:08:34 +01:00
61 changed files with 1257 additions and 229 deletions
+22 -7
View File
@@ -147,8 +147,15 @@ jobs:
if: steps.docs-check.outputs.docs_only != 'true'
with:
python-version: "3.11"
cache: pip
- run: pip install -r requirements.txt
# 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
if: steps.docs-check.outputs.docs_only != 'true'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.docs-check.outputs.docs_only != 'true'
@@ -157,15 +164,25 @@ 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'
- name: Install FFmpeg for media integration tests
# One apt pass for FFmpeg (media integration tests) and bubblewrap
# (containment, verified in the next step).
- name: Install FFmpeg and bubblewrap
if: steps.docs-check.outputs.docs_only != 'true'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg
sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap
command -v ffmpeg
ffmpeg -version | head -n 1
@@ -174,8 +191,6 @@ 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
@@ -223,4 +238,4 @@ jobs:
if: steps.docs-check.outputs.docs_only != 'true'
env:
PYTEST_SHARD: ${{ matrix.shard }}
run: python -m pytest -q -rs --shard "$PYTEST_SHARD"
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
+21
View File
@@ -67,3 +67,24 @@ 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.
+8 -2
View File
@@ -1,4 +1,6 @@
<h1 align="center">Odysseus</h1>
<p align="center">
<img src="assets/branding/odysseus-wordmark.png" alt="Odysseus" width="238">
</p>
<p align="center">
A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
@@ -15,6 +17,10 @@
<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
@@ -53,7 +59,7 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration
## Demo
The [Odysseus landing page](https://odysseus-dev.github.io/odysseus/) gives a text-only overview of each feature. Its source lives under [`website/`](website/).
A full hover-to-play tour lives on the [Odysseus landing page](https://odysseus-dev.github.io/odysseus/). Its source lives under [`website/`](website/).
## Contributing
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+18 -2
View File
@@ -27,8 +27,23 @@ echo " port: $PORT"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
# Use the macOS default application icon; no branding-derived artwork is bundled.
echo " icon: macOS default"
# ── 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
cp -R "$REPO_DIR/licenses" "$APP/Contents/Resources/licenses"
cp "$REPO_DIR/THIRD_PARTY_PROVENANCE.json" "$REPO_DIR/ACKNOWLEDGMENTS.md" "$APP/Contents/Resources/"
@@ -45,6 +60,7 @@ 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/>
+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.
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.
`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.
## 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 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.
`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.
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.
+18 -9
View File
@@ -801,15 +801,18 @@ 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)) or not _visible_bound_resource(entry.path):
if (_is_denied_tool_path(os.path.realpath(entry.path), snapshot=snapshot)
or not _visible_bound_resource(entry.path)):
continue
try:
is_dir = entry.is_dir(follow_symlinks=False)
@@ -864,10 +867,12 @@ 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 "*?["):
@@ -884,7 +889,8 @@ 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) and _visible_bound_resource(cand):
if (inside and os.path.exists(cand) and not _is_denied_tool_path(cand, snapshot=snapshot)
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).
@@ -894,7 +900,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)):
if not _can_traverse_tool_path(os.path.realpath(dp), snapshot=snapshot):
dns[:] = []
continue
# Prune skipped dirs before descending (unlike rglob which
@@ -905,7 +911,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)))
and _can_traverse_tool_path(os.path.realpath(os.path.join(dp, d)), snapshot=snapshot)
]
for name in fns + dns:
full = os.path.join(dp, name)
@@ -913,7 +919,8 @@ 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)) or not _visible_bound_resource(full):
if (_is_denied_tool_path(os.path.realpath(full), snapshot=snapshot)
or not _visible_bound_resource(full)):
continue
try:
mtime = os.stat(full).st_mtime
@@ -981,6 +988,7 @@ 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")
@@ -992,6 +1000,7 @@ class GrepTool:
if bound is not None:
bound.validate()
files = []
snapshot = _control_plane_snapshot()
def check_deadline():
if time.monotonic() >= deadline:
@@ -1002,7 +1011,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):
if not _path_within(canonical, base) or _is_denied_tool_path(canonical, snapshot=snapshot):
return
if bound is not None:
try:
@@ -1037,7 +1046,7 @@ class GrepTool:
while pending_directories:
check_deadline()
directory = pending_directories.pop()
if not _can_traverse_tool_path(directory):
if not _can_traverse_tool_path(directory, snapshot=snapshot):
continue
with os.scandir(directory) as entries:
for entry in entries:
@@ -1052,8 +1061,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)
and (bound is None or _is_denied_tool_path(canonical)
and _can_traverse_tool_path(canonical, snapshot=snapshot)
and (bound is None or _is_denied_tool_path(canonical, snapshot=snapshot)
or _visible_bound_resource(canonical))):
pending_directories.append(canonical)
else:
+32 -9
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 ±5-minute window and hasn't been pinged
within the last 25 minutes. Mirrors `action_ping_events` for calendar.
`due_date` falls in the current ±90-second window and hasn't been delivered
through its configured channel in the last 25 minutes.
State (`data/note_pings.json`): {note_id: iso_ts_of_last_ping}. Pruned
Per-owner state: {note_id: {at, channel}}, with legacy timestamp support. Pruned
on each run by dropping entries for notes that are gone/archived/replied.
"""
try:
@@ -2309,6 +2309,11 @@ 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
@@ -2373,20 +2378,27 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
due = _parse_due(n.due_date)
if not due:
continue
# Inside the ±5min window?
# Inside the due 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:
continue
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")
except Exception:
pass
# Compose + dispatch.
@@ -2410,15 +2422,26 @@ 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
await dispatch_reminder(
result = await dispatch_reminder(
title=title, note_body=body, note_id=n.id,
owner=n.owner or owner or "",
queue_browser=not browser_recent,
)
cache[n.id] = now.isoformat()
sent.append(title)
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)
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)
@@ -2429,7 +2452,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), none due in ±{WINDOW_SEC}s")
raise TaskNoop(f"scanned {len(notes)} note(s), no reminders delivered")
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
+53 -3
View File
@@ -96,6 +96,41 @@ _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:
@@ -6782,6 +6817,10 @@ 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:
@@ -6840,11 +6879,12 @@ 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
raise ValueError(
validator_error = (
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:
@@ -6938,6 +6978,7 @@ 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 '')
@@ -6946,6 +6987,7 @@ 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,
@@ -6959,6 +7001,7 @@ 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 (
@@ -7015,10 +7058,11 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
if canonical(name) in permanently_suppressed_tools:
calls += 1
terminal_suppression_violation = True
raise ValueError(
validator_error = (
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
@@ -7111,6 +7155,7 @@ 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,
@@ -7316,8 +7361,13 @@ 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': _public_preview_tool_error(exc, execution_attempted=execution_attempted),
'error': (
validator_error if validator_error and not execution_attempted
else _public_preview_tool_error(exc, execution_attempted=execution_attempted)
),
'error_category': 'tool_execution_error' if execution_attempted else 'invalid_tool_arguments',
'exit_code': 1,
}
+9 -5
View File
@@ -964,24 +964,28 @@ 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) -> bool:
"""Apply every path deny to a canonical traversal result."""
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.
"""
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)
or _control_plane_path(resolved, snapshot=snapshot)
)
def _can_traverse_tool_path(resolved: str) -> bool:
def _can_traverse_tool_path(resolved: str, *, snapshot=None) -> 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)
return not _control_plane_path(resolved, snapshot=snapshot)
return any(
_path_within(readable, resolved)
for readable in _agent_readable_data_subdirs()
+19
View File
@@ -130,11 +130,30 @@ 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,6 +704,7 @@ 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,6 +90,12 @@
.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.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+1
View File
@@ -13,6 +13,7 @@
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(){
+61 -1
View File
@@ -18,7 +18,13 @@
'use strict';
// Click-as-button rows we want reachable by keyboard.
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
var ROW_SELECTOR = [
'#sidebar .list-item',
'#user-bar-profile',
'.sidebar-brand',
'.export-dropdown-item',
'.dropdown-item'
].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
@@ -55,6 +61,31 @@
(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
@@ -118,9 +149,38 @@
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" 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>`;
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>`;
}
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">✖</button>
<button class="close-btn" id="cal-close" aria-label="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')) return;
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) 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')) return;
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) 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')) return;
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) 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')) return;
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) 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 href="${safeMeetingUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation();" title="Join meeting">${safeMeetingUrl}</a>`;
return `<a class="cal-event-link" href="${safeMeetingUrl}" target="_blank" rel="noopener" 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 href="${safe}" target="_blank" rel="noopener" onclick="event.stopPropagation();">${safe}</a>`;
html += `<a class="cal-event-link" href="${safe}" target="_blank" rel="noopener">${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 href="${mapUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation();" title="Open in OpenStreetMap">${_e(loc)}</a>`;
return `<a class="cal-event-link" href="${mapUrl}" target="_blank" rel="noopener" title="Open in OpenStreetMap">${_e(loc)}</a>`;
}
// ── Open / Close ──
+69 -1
View File
@@ -44,6 +44,66 @@ 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;
@@ -4027,6 +4087,7 @@ 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;
@@ -6081,7 +6142,13 @@ import { invalidateSettings } from './appConfig.js';
cache: 'no-store',
});
if (!_getForegroundStreamState() || _backgroundStreams.has(sid)) return;
if (res.status !== 404) 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;
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
if (active.abortCtrl && !active.abortCtrl.signal.aborted) {
@@ -6443,6 +6510,7 @@ 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,6 +96,21 @@ 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: '',
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"></path><path d="M16 6v12"></path><path d="M20 6v12"></path></svg>',
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"></path><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"></path></svg>',
ollama: '',
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"></path></svg>',
};
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: '',
sglang: '<span aria-hidden="true" style="display:block;width:13px;height:13px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
mlx_lm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
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: '',
ollama: '<img src="/static/icons/ollama-mark-crop.png" alt="" aria-hidden="true" width="13" height="13" style="display:block;width:13px;height:13px;object-fit:contain;" />',
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
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>',
+7 -6
View File
@@ -1498,9 +1498,10 @@ function _rerenderCachedModels() {
const _ggufOptions = _ggufChoices.map(f =>
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
).join('');
const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
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 _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
const _modelPathValue = _savedModelPath || _defaultServeModel;
const _modelPathValue = _isMiniMaxM3 && (!_savedModelPath || _savedModelPath === repo) ? _minimaxM3Snapshot : _savedModelPath;
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
// Build save slots
const _allPresets = _loadPresets();
@@ -2210,11 +2211,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: '',
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
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: '',
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
};
@@ -2234,7 +2235,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() {
@@ -2242,7 +2243,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('');
+130 -98
View File
@@ -1996,6 +1996,17 @@ 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
@@ -2290,8 +2301,16 @@ 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) => {
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return;
const canPlace = placementStartedOnPage;
placementStartedOnPage = false;
if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return;
if (!_pdfDropMode) return;
const rect = pageWrap.getBoundingClientRect();
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
@@ -2423,9 +2442,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Set handle visibility together; clicking/tapping the annotation itself
// brings hidden controls back.
let handlesRequested = _isTouch;
const _setHandlesVisible = (show) => {
if (!_pdfAnnotationDragging) handlesRequested = show;
const dismissed = wrap.dataset.controlsDismissed === '1';
const v = (show && !dismissed) ? '' : 'none';
const v = (show && !dismissed && !_pdfAnnotationDragging) ? '' : 'none';
del.style.display = v;
grip.style.display = v;
resize.style.display = v;
@@ -2446,7 +2467,8 @@ 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 };
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible,
_refreshHandles: () => _setHandlesVisible(handlesRequested) };
if (kind === 'check') {
// Stamp checkmark — value is fixed, nothing to listen for.
@@ -2568,7 +2590,12 @@ 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);
@@ -2580,6 +2607,7 @@ 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';
@@ -2598,11 +2626,14 @@ 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.
@@ -2611,6 +2642,7 @@ 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';
@@ -2628,11 +2660,14 @@ 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
@@ -3416,9 +3451,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}).join('');
if (!changed && /<[^>]+>/.test(text) && typeof document !== 'undefined') {
const probe = document.createElement('template');
const probe = document.createElement('div');
probe.innerHTML = text;
const plain = (probe.content.textContent || '').trim();
const plain = (probe.innerText || probe.textContent || '').trim();
const plainClean = plain ? _sanitizeOutgoingEmailBody(plain) : plain;
if (plainClean !== plain) return plainClean;
}
@@ -3530,9 +3565,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
function _emailHtmlToPlainText(html) {
if (typeof document === 'undefined') return String(html || '');
const d = document.createElement('template');
const d = document.createElement('div');
d.innerHTML = String(html || '');
return d.content.textContent || '';
return d.innerText || d.textContent || '';
}
function _sanitizedRichTextHtml(rich) {
@@ -5393,20 +5428,15 @@ 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"></span>
<span class="email-odysseus-attach-thumb">${src ? `<img src="${_escHtml(src)}" alt="">` : ''}</span>
<span class="email-odysseus-attach-main">
<span class="email-odysseus-attach-title">${_escHtml(label)}</span>
<span class="email-odysseus-attach-meta">${_escHtml(item.filename || 'image')}</span>
</span>
`;
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>
@@ -6735,10 +6765,10 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
.trim();
};
const splitCurrent = _splitEmailReplyQuote(currentBody);
const ownBody = document.createElement('template');
const ownBody = document.createElement('div');
ownBody.innerHTML = String(splitCurrent.body || '');
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);
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);
if (!isReplaceableDraft) {
if (uiModule) uiModule.showToast('Reply already has text');
return;
@@ -7630,8 +7660,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)" 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>
<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>
<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>
@@ -15065,6 +15095,20 @@ 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';
@@ -15073,6 +15117,22 @@ 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');
@@ -15093,11 +15153,7 @@ 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 });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = safeName + ver + ext;
a.click();
URL.revokeObjectURL(a.href);
_downloadDocumentBlob(blob, safeName + ver + ext);
}
// "Import from device" — open a file picker, upload, and immediately open
@@ -15203,6 +15259,7 @@ 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');
@@ -15239,7 +15296,8 @@ 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';
menu.style.zIndex = '9999';
const pane = document.querySelector('.doc-editor-pane');
menu.style.zIndex = String(Math.max(topPortalZ(), (pane && parseInt(getComputedStyle(pane).zIndex, 10) || 0) + 1));
const langLabel = lang ? lang.toUpperCase() : 'TXT';
// Form-backed markdown doc → primary export is the filled PDF, not the
@@ -15273,11 +15331,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 browser print path would rebuild the extracted text and
// Running the generic html2pdf path would rebuild the extracted text and
// destroy the original page layout, images, and form structure.
if (!isForm && (_isDocxLang(lang) || typeof window.print === 'function')) {
if (!isForm) {
options.push({
label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print / save PDF',
label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF',
fn: _isDocxLang(lang) ? () => convertOriginalDocument('pdf') : exportAsPdf,
});
}
@@ -15305,16 +15363,25 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
});
document.body.appendChild(menu);
// 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.
// 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';
const mh = menu.offsetHeight;
if (rect.bottom + mh > window.innerHeight - 8) {
menu.style.top = 'auto';
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
}
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';
// 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(); });
const close = bindMenuDismiss(menu, () => { menu.remove(); },
ev => !menu.contains(ev.target) && !anchor?.contains(ev.target));
}
function _richTextExportCss() {
@@ -15374,11 +15441,7 @@ 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' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = _getExportBaseName() + '.html';
a.click();
URL.revokeObjectURL(a.href);
_downloadDocumentBlob(blob, _getExportBaseName() + '.html');
if (uiModule) uiModule.showToast('Exported as HTML');
}
@@ -15440,13 +15503,15 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
if (!activeDocId) return;
const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return;
if (typeof window.print !== 'function') {
if (uiModule) uiModule.showError('Browser printing is unavailable.');
try {
await ensureHtml2Pdf();
} catch (e) {
if (uiModule) uiModule.showError('Failed to load PDF library');
return;
}
const lang = document.getElementById('doc-language-select')?.value || '';
const text = textarea.value || '';
// Render content as HTML for the browser print dialog.
// Render content as HTML for PDF
let html;
if (_isRichTextLang(lang)) {
html = text;
@@ -15459,57 +15524,28 @@ 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>';
}
// 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));
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);
}
// 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) {
@@ -15953,11 +15989,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
});
const blob = await window.docx.Packer.toBlob(doc);
const baseName = _getExportBaseName();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = baseName + '.docx';
a.click();
URL.revokeObjectURL(a.href);
_downloadDocumentBlob(blob, baseName + '.docx');
if (uiModule) uiModule.showToast('Exported as DOCX');
}
+1
View File
@@ -66,3 +66,4 @@ export function parseMarkdownOutline(source) {
}
return entries;
}
+38 -3
View File
@@ -1302,8 +1302,11 @@ 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;
@@ -1358,9 +1361,37 @@ 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 || '',
@@ -2211,7 +2242,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">\u2716</button>
<button class="close-btn" id="email-lib-close" aria-label="Close">\u2716</button>
</div>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;overflow:hidden;">
@@ -4794,7 +4825,7 @@ export async function _refreshUnreadBadge({ unreadCountOverride = null, preserve
} catch (_) { _syncUnreadTabBadge(0); }
}
export async function _loadEmails({ force = false, useCache = true } = {}) {
export async function _loadEmails({ force = false, useCache = true, skipFastPath = false } = {}) {
const seq = ++_libLoadSeq;
state._libLoading = true;
const accountAtStart = state._libAccountId || '';
@@ -4848,11 +4879,15 @@ export async function _loadEmails({ force = false, useCache = true } = {}) {
}
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 });
}
@@ -4888,7 +4923,7 @@ export async function _loadEmails({ force = false, useCache = true } = {}) {
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) {
if (!cached && cacheable && !force && !skipFastPath) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 450);
try {
+97 -7
View File
@@ -1278,6 +1278,7 @@ 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">
@@ -1328,6 +1329,28 @@ 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
@@ -1351,6 +1374,11 @@ 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
@@ -1376,7 +1404,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">
<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-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>
@@ -1415,10 +1443,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)}">
<div class="gallery-card" data-id="${_esc(img.id)}" tabindex="0" role="button" aria-label="${_esc(labelText)}">
<span class="gallery-select-dot" style="display:none;"></span>
<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">
<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">
<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)
@@ -1464,6 +1492,14 @@ 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
@@ -1575,7 +1611,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">
<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">
<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>
@@ -1804,7 +1840,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'; };
const _setMenu = (show) => { menu.hidden = !show; menu.style.display = show ? 'block' : 'none'; menuBtn.setAttribute('aria-expanded', show ? 'true' : 'false'); };
_setMenu(false);
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -2235,7 +2271,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">&times;</button>
<button class="modal-close" id="gallery-close" aria-label="Close">&times;</button>
</div>
<div class="gallery-tabs">
<button class="gallery-tab active" data-tab="images">
@@ -2251,6 +2287,7 @@ 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>
@@ -2318,6 +2355,13 @@ 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>
@@ -2558,6 +2602,52 @@ 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;
+7 -1
View File
@@ -582,13 +582,19 @@ 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="#" onclick="document.getElementById(\'user-bar-admin\')?.click();return false;" class="accent-link">Open Admin to add endpoints</a>'
+ '<a href="#" id="models-open-admin" 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" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${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')) return; // checkbox handles itself
if (e.target.closest('.note-card-cb, a')) return; // checkbox handles itself; links open without toggling
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)}" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button></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>` : ''}
<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" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button>`;
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="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.
+4 -1
View File
@@ -80,7 +80,10 @@ 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(() => { _reconnectActive(); }, 20000);
_activePollInterval = setInterval(() => {
if (document.hidden) return;
_reconnectActive();
}, 60000);
}
// Allow an immediate adopt when the chat stream signals a new research
+3
View File
@@ -103,6 +103,9 @@ 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)}"/>
<img src="${_esc(dataUrl)}" alt=""/>
<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>
+25 -2
View File
@@ -5336,6 +5336,29 @@ 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;
@@ -5373,7 +5396,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" 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>`;
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>`;
}).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>`);
@@ -5690,7 +5713,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" 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="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="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>
+22 -1
View File
@@ -34,6 +34,8 @@ 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 = '';
@@ -79,6 +81,24 @@ 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) {
@@ -171,7 +191,7 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
appendMode = null; // whatever was streaming is now frozen
tailShownLen = 0;
}
renderTail(fullText.slice(committedLen));
scheduleTailRender(fullText.slice(committedLen));
} catch (err) {
degraded = true;
console.error('streamingRenderer: falling back to full render', err);
@@ -187,6 +207,7 @@ 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">✖</button>
<button class="close-btn" id="tasks-close" aria-label="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,9 +31,33 @@ 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;
@@ -358,6 +382,14 @@ 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,6 +6,7 @@
<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() {
+6 -1
View File
@@ -7,5 +7,10 @@
"display": "standalone",
"orientation": "any",
"background_color": "#282c34",
"theme_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" }
]
}
+5 -1
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-v657-publication-plan-b';
const CACHE_NAME = 'odysseus-v658-publication-assets-restore';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -134,6 +134,10 @@ 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;
+41 -7
View File
@@ -14,23 +14,57 @@ 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');
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());
// 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
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 900 });
assert(await page.locator('.feature-description').first().isVisible());
assert(await page.locator('.preview-panel').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(!manifest.icons);
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
for (const filename of ['index.html', 'login.html']) {
const html = await (await page.request.get(origin + '/static/' + filename)).text();
assert(!/<link[^>]+rel=["']apple-touch-icon/.test(html));
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`);
}
// A small host DOM runs the actual export function and markdown renderer.
+38 -1
View File
@@ -176,6 +176,41 @@ 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.
@@ -331,7 +366,9 @@ 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."""
+125
View File
@@ -0,0 +1,125 @@
"""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"
+45 -11
View File
@@ -30,23 +30,49 @@ 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()
removed = re.findall(r'^- SAN-\d+: `([^`]+)`$', ledger, re.M)
assert len(removed) == 21
# Inspect tracked text, including files omitted by parity-audit exclusions.
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.
tracked = subprocess.check_output(['git', 'ls-files', '-z'], cwd=ROOT).decode().split('\0')
for path in removed:
assert not (ROOT / path).exists()
for path in still_omitted:
assert not (ROOT / path).exists(), f"omitted asset must not exist: {path}"
filename = Path(path).name
for name in tracked:
if name == "PUBLICATION_ASSET_DECISIONS.md":
continue # Historical ledger entries are intentionally non-resource references.
if name in {"PUBLICATION_ASSET_DECISIONS.md", "tests/test_publication_plan_b.py"}:
continue # Historical ledger and test assertions are intentionally non-resource references.
file = ROOT / name
if not file.is_file():
continue
try:
content = file.read_text()
content = file.read_text(encoding="utf-8")
except UnicodeError:
continue
assert Path(path).name not in content, (path, name)
assert filename not in content, (path, name)
def test_cold_catalog_and_refresh_use_mutable_data(monkeypatch, tmp_path):
@@ -113,12 +139,20 @@ 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 not manifest.get('icons')
assert 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: text-only website, saved fonts, escaped print/math."""
"""Real Chromium DOM: restored website media, 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)
+3 -4
View File
@@ -1,8 +1,7 @@
"""Regression guard for the README title presentation.
The original ASCII-art banner needed a code fence to preserve whitespace.
Publication now opens with a centered text heading after omitting the wordmark
artwork. Keep the title identity and the historical ASCII-fencing guard.
Keep the wordmark title identity and the historical ASCII-fencing guard.
"""
from pathlib import Path
@@ -19,9 +18,9 @@ def _fenced_segments(text: str):
return parts[1::2]
def test_readme_opens_with_text_title():
def test_readme_opens_with_wordmark_title():
head = "\n".join(README.read_text(encoding="utf-8").splitlines()[:15])
assert head.startswith('<h1 align="center">Odysseus</h1>'), "README must open with the Odysseus text title"
assert 'alt="Odysseus"' in head, "README must open with the Odysseus wordmark image"
def test_reintroduced_ascii_banner_stays_fenced():
+42
View File
@@ -222,3 +222,45 @@ 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.
+195 -20
View File
@@ -273,12 +273,87 @@
.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; }
/* 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; }
/* 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); }
}
/* Get started */
.start {
@@ -523,28 +598,62 @@
</div>
</section>
<!-- FEATURE DESCRIPTIONS -->
<section id="features-overview">
<!-- PREVIEWS: hover/tap to expand + play -->
<section id="previews">
<div class="wrap">
<div class="center">
<h2 class="h">Explore the workspace</h2>
<p class="sub center">Tools for local AI and everyday work.</p>
<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>
</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 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>
</div>
</section>
<!-- HOW IT STARTED -->
<section id="how">
<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>
<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>
@@ -681,6 +790,72 @@
}
})();
// 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/ text-only landing page (index.html)
website/ landing page (index.html) + preview clips
```
## Data
Binary file not shown.