diff --git a/.gitignore b/.gitignore index 1ae2878b5..d51361f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,8 @@ services/data/ # Test capture artifacts (browser session dumps may contain personal content) .playwright-mcp/ +test-results/ +playwright-report/ # OS .DS_Store diff --git a/.gitleaks.toml b/.gitleaks.toml index 0eec3f1ae..8543c9666 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -35,3 +35,15 @@ regexTarget = "secret" regexes = [ '''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''', ] + +[[allowlists]] +description = "Synthetic dummy API key in trace artifact sanitizer test fixture" +targetRules = ["generic-api-key"] +condition = "AND" +paths = [ + '''(?:^|/)tests/test_trace_artifact_sanitizer\.py$''', +] +regexTarget = "secret" +regexes = [ + '''^sk-123456789$''', +] diff --git a/Dockerfile b/Dockerfile index 936f35b90..fe6a6792f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,22 @@ +# Both stages need the fixed native decoder, including curl in the builder and +# distro Node in the runtime. A cached Python base may predate the Debian fix. +FROM python:3.14.9-slim AS brotli-base +COPY scripts/check_brotli.py /usr/local/bin/check-brotli.py +COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py +# Check inherited trust before the first HTTPS package request, retaining Debian's +# signed archive verification. Refresh only the explicit native dependency. +RUN python /usr/local/bin/check-ca-trust.py --system \ + && sed -i 's|http://deb.debian.org/|https://deb.debian.org/|g' /etc/apt/sources.list.d/debian.sources \ + && apt-get update && apt-get install -y --no-install-recommends libbrotli1 \ + && python /usr/local/bin/check-brotli.py \ + && rm -rf /var/lib/apt/lists/* + # ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ---- # basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'], # which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so # the final image / Cookbook never has to compile the broken sdists. See # docker/build-realesrgan-wheels.sh for the full rationale. -FROM python:3.14.9-slim AS realesrgan-wheels +FROM brotli-base AS realesrgan-wheels RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py @@ -14,7 +27,7 @@ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: -- COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels -FROM python:3.14.9-slim +FROM brotli-base # System deps. tmux is required by Cookbook for background downloads/serves. # openssh-client is required for Cookbook remote server tests, setup, probes, @@ -52,6 +65,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libglib2.0-0t64 \ libxcb1 \ libmagic1 \ + && python /usr/local/bin/check-brotli.py --node /usr/bin/node \ && rm -rf /var/lib/apt/lists/* # Refresh both Python trust bundles before installing application dependencies. @@ -132,7 +146,8 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \ # Check gRPC's effective defaults after all Python dependencies are installed. # The application minimum fixes its bundled roots; the checked override also # keeps the effective Docker selection consistent with system trust. -RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc +RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc \ + && python /usr/local/bin/check-brotli.py --node /usr/bin/node # Copy app code COPY . . diff --git a/docs/QUICKEDIT_COVERAGE.md b/docs/QUICKEDIT_COVERAGE.md new file mode 100644 index 000000000..895d84502 --- /dev/null +++ b/docs/QUICKEDIT_COVERAGE.md @@ -0,0 +1,255 @@ +# Quick Edit Coverage Audit + +Audit of how much of the PhotoCraft editor is reachable through **Quick Edit** +(the natural-language editor agent). Built against build **0.20.44** (HARNESS +bridge fan-out fix `4dd11826`) and client commit `e78b5a96`. + +Quick Edit executes in two stages: + +1. **Plan** — `static/js/editor/editor-agent.js` posts `{prompt, state, + screenshot}` to `/api/editor/agent`. HARNESS `routes/editor_agent_routes.py` + plans with the shared style guide (`src/editor_style_guide.py`) plus an LLM, + validates every step against its own `OP_CATALOG`, then executes. +2. **Execute** — only `ai_edit_image` / `generate_image` run server-side; every + other op is delivered to the page over the editor bridge (SSE) and run by the + client control surface (`window.geEditorControl.invoke`, catalog in + `static/js/galleryEditor.js`). A local fallback planner lives in + `static/js/editor/nl-edit.js`. + +There are **three** catalogs that must agree: + +| Catalog | File | Owner | +|---|---|---| +| Client control ops | `static/js/galleryEditor.js` | PHOTOSHOP | +| Server `OP_CATALOG` | `routes/editor_agent_routes.py` | HARNESS | +| Style guide / planner | `src/editor_style_guide.py` + `nl-edit.js` | HARNESS + PHOTOSHOP | + +Legend: **C** = in client catalog · **S** = in server `OP_CATALOG` (incl. +`NEW_OP_NAMES`) · **P** = the planner/style guide emits it for some phrasing. +A capability is **usable from Quick Edit** only when the planner can emit it +(**P**) and the op exists on the side that runs it. + +--- + +## 1. Menu bar (12 menus) + +| Capability | Trigger phrasing | Op | C | S | P | Status | +|---|---|---|---|---|---|---| +| File → Import | "open this image" | `document.importImage` | ✔ | ✔ | partial | OK | +| File → Load project | "open my project" | `document.openPsd` | ✔ | ✘ | ✘ | **Server gap** | +| File → Save / Save copy / Export PSD | "save as psd" | `document.savePsd` | ✔ | ✔ | ✔ | OK | +| File → Export | "export png" | `export.png` | ✔ | ✔ | ✔ | OK | +| File → Save project | — | (project store) | n/a | n/a | ✘ | Out of scope (not an edit op) | +| Edit → Undo/Redo/History | "undo" | `history.undo` / `redo` | ✔ | ✔ | ✔ | OK | +| Edit → Free Transform | "free transform" | `transform.start` | ✔ | ✔ | ✔ | OK | +| Edit → Transform → Rotate 90/180 | "rotate this layer 90" | `layer.rotate` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) | +| Edit → Transform → Flip H/V | "flip this layer" | `layer.flip` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) | +| Image → Fill | "fill the whole image with white" | — | ✘ | ✘ | ✘ | **No op** | +| Image → Canvas Size | "make the canvas wider" | `document.resizeCanvas` | ✔ | ✔ | partial | OK | +| Image → Image Size | "resize the image to 1200px" | `document.resizeCanvas` | ✔ | ✔ | partial | OK | +| Image → Rotate 90/180 (whole image) | "rotate the whole image 90" | `document.rotate`/all-layers | ✘ | ✘ | ✘ | **No control op** | +| Image → Flip H/V (whole image) | "flip the whole canvas" | `document.flip`/all-layers | ✘ | ✘ | ✘ | **No control op** | +| Image → Mode RGB/Grayscale | "make it black and white" | `adjustments.apply` black-white | ✔ | ✔ | ✔ | OK (mode via adjustment) | +| Image → Adjustments → Invert | "invert the colours" | `adjustments.apply` invert | ✔ | ✔ | ✔ | OK (fast path colour-inverts the active layer; "invert the selection" stays Select ▸ Inverse) | +| Image → Mode CMYK/Lab, Bits 16/32 | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) | +| Layer → New | "add a layer" | `layers.addRaster` | ✔ | ✔ | partial | OK | +| Layer → Duplicate | "duplicate the layer" | `layers.duplicate` | ✔ | ✔ | partial | OK | +| Layer → Group | "group the layers" | `layers.group` | ✔ | ✔ | ✘ | **Server OK, planner gap** | +| Layer → Rasterize | "rasterize the text" | `layers.rasterize` | ✔ | ✔ | ✔ | OK | +| Layer → Delete | "delete the layer" | `layers.delete` | ✔ | ✔ | partial | OK | +| Layer → Merge Down | "merge down" | `layers.mergeDown` | ✔ | ✘ | ✘ | **Server gap** | +| Layer → Merge All / Flatten | "flatten the image" | `layers.flatten` | ✔ | ✘ | ✘ | **Server gap** | +| Layer → Select All | "select all layers" | (multi-select) | ✔ | ✔ | ✔ | OK (fast path selects every layer) | +| Layer → Combine (unite/subtract/…) | "combine the shapes" | `_combineSelectedShapes` | ✔ | ✔ | ✔ | OK (fast path selects the shape layers and combines) | +| Layer → Fill | "fill the layer with blue" | `selection.fill` / flood | partial | ✘ | ✘ | **Gap** | +| Layer → Layer Mask (reveal/hide/apply/invert/feather) | "add a layer mask" | `layers.addMaskFromSelection` | ✔ | ✔ | ✔ | OK | +| Layer → Opacity | "set the layer opacity to 50%" | `layers.setOpacity` | ✔ | ✔ | partial | OK (fast path sets the active layer's opacity) | +| Layer → Blend Mode | "set the blend mode to multiply" | `layers.setBlend` | ✔ | ✔ | partial | OK (fast path sets the active layer's blend mode) | +| Type → Add | "add a title" | `text.add` | ✔ | ✔ | ✔ | OK | +| Type → Character | "make the text bigger" | `text.edit` | ✔ | ✘ | ✘ | **Server gap + planner gap** | +| Type → Paragraph | "centre the text" | `text.layout` | ✔ | ✔ | partial | OK | +| Type → Warp | "warp the text" | `_rerenderTextLayer` (warp) | ✔ | ✔ | ✔ | OK | +| Select → All/Deselect/Invert | "invert the selection" | `selection.*` | ✔ | ✔ | ✔ | OK | +| Select → Reselect | "reselect" | `_reselectSelection` | ✔ | ✔ | ✔ | OK | +| Select → Transform | "transform the selection" | `transform.start` | ✔ | ✔ | partial | OK | +| Select → Refine | "refine the edge" | `selection.feather/expand` | ✔ | ✘ | ✘ | **Server gap** | +| Select → Color Range | "select the blue sky" | `selection.colorRange` | ✔ | ✔ | partial | OK | +| Select → Select and Mask | "select the subject" | `selection.subject` | ✔ | ✔ | ✔ | OK | +| Select → Save | "save the selection" | `_saveNamedSelection` | ✔ | ✔ | ✔ | OK | +| Select → Load | "load the selection as sky" | `_loadNamedSelection` | ✔ | ✔ | ✔ | OK | +| Filter → every filter (30+) | "pixelate the image" | `filters.apply` | ✔ | ✔ | ✔ | OK (fast path covers pixelate/emboss/twirl/pinch/spherize/ripple/solarize/find-edges/crystallize/noise/despeckle/unsharp + blur/sharpen/vignette) | +| Filter → Blur → Motion / Zoom | "motion blur" / "zoom blur" | `filters.apply` (blur-motion / blur-zoom) | ✔ | ✔ | ✔ | OK (fast path maps motion blur and zoom blur to their Filter-menu variants) | +| Filter → Layer effects (drop shadow, stroke, colour overlay, outer glow) | "add a drop shadow" | `layers.addStyle` | ✔ | ✔ | ✔ | OK (fast path adds drop shadow / stroke / outer glow / colour & gradient overlay; colour parsed from the prompt) | +| View → Rulers/Grid/Snap/Clear guides | "show the grid" | `#ge-view-menu` | ✔ | ✔ | ✔ | OK | +| Window → panels | — | n/a | n/a | n/a | ✘ | UI-only | +| 3D / Plugins | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) | + +## 2. Tools + +Toolbar tools are interactive, not bridge ops, so coverage is about whether the +**effect** of a tool is reachable by prompt. + +| Tool | NL-reachable? | Notes | +|---|---|---| +| Move / Transform | `transform.*` | OK (transform.start/apply/cancel) | +| Hand / Zoom | — | View only | +| Crop | `document.crop` + format guard | OK | +| Text / Shape | `text.add`, `text.layout` | Shape has no add-shape op — **gap** | +| Brush / Eraser / Dodge / Burn / Smudge / Clone / Heal / Sharpen | — | Interactive painting; no NL op — **gap** (out of scope for planner) | +| Gradient | — | **No op** | +| **Paint Bucket** | `fill.flood` | **Server gap + planner gap** (op exists client-side only) | +| Eyedropper | — | UI only | +| Marquee / Lasso / Wand / Quick Select / Object Select / Pen / Path | `selection.rectangle/ellipse/polygon/subject/…` | OK for region/reference selects; lasso/pen paths have `selection.polygon` only | +| SAM / Inpaint / Rembg | `selection.subject`, `ai.removeObject`, `ai.removeBackground` | OK | +| **Pixel brush + grid** | — | UI state only; no op — **gap** (acceptable: it is a brush mode, not an edit op) | + +## 3. Right-click context menu + +| Item | Backing op | Reachable by prompt? | +|---|---|---| +| Brush size / preset / Pixel Brush | `state` UI | No (interactive) | +| Select All / Deselect / Inverse / Feather / Expand / Contract / Border / Smooth | `selection.*` | Yes | +| Transform Selection / Refine Edge | `transform.start`, `selection.feather` | Partial | +| Apply / Cancel Crop | `document.crop` | Yes | +| Commit / Cancel / Free Transform | `transform.apply/cancel/start` | Yes | +| Add Text Layer / Rasterize Text | `text.add`, `layers.rasterize` | Yes (rasterize OK) | +| Copy Color Hex / Swap Colors | `state.color` | OK (fast path swaps / resets the foreground & background swatches) | +| Fill Selection w/ Foreground | `selection.fill` | OK (fast path fills the selection/canvas with the foreground or background colour) | +| Cut / Copy / Paste | `selection.copy/paste` | **Gap** (no planner intent) | +| Rotate 90/180, Flip H/V | `layer.rotate/flip` | **Server+planner gap** (same as Edit menu) | +| Undo / Redo | `history.*` | Yes | + +## 4. Adjustments + +`adjustments.apply` / `adjustments.applyMasked` cover all adjustment types +(brightness-contrast, white-balance, shadows-highlights, vibrance, clarity, +vignette, black-white, …). The style guide emits several. **Status: OK** +(planner covers the common ones; the long tail is reachable only via explicit +wording). + +## 5. Filters + +`filters.apply` / `filters.applyMasked` run every filter the UI lists. The +planner only emits **sharpen**, **blur-gaussian**, and **vignette** via intents. +**Gap (planner):** no intent for noise, pixelate, stylize, distort, render, +unsharp, sharpen-edges, colour-overlay/drop-shadow/stroke. +**Gap (HARNESS):** `filters.apply`/`applyMasked` are in the server catalog, so +the fix is style-guide intents, not catalog. + +## 6. Layer styles + +`layers.addStyle {type, params}` exists in both catalogs. **Planner never emits +it**, so "add a drop shadow to the title", "add a stroke", "colour overlay" fail. +**Gap (planner).** + +## 7. Selection ops + +`selection.rectangle/ellipse/polygon/fill/delete/copy/paste/feather/expand/ +contract/selectAll/deselect/invert/colorRange/subject/samText/face/sky/ +background/hasMask/maskBounds/exportMask/regionLuminance`. **Status: mostly OK.** +Missing: reselect, save-selection. + +## 8. Document ops + +| Capability | Op | C | S | P | +|---|---|---|---|---| +| Crop | `document.crop` | ✔ | ✔ | ✔ | +| Resize canvas / image | `document.resizeCanvas` | ✔ | ✔ | partial | +| **Extend canvas** | `document.extend` | ✔ | ✘ | ✘ | +| Save PSD | `document.savePsd` | ✔ | ✔ | ✔ | +| Export layers | `document.exportLayers` | ✔ | ✘ | ✘ | +| Duplicate / Create / Switch / List | `document.*` | ✔ | ✘ | partial | + +## 9. AI edit & generate modes + +| Mode | Op | Notes | +|---|---|---| +| Generative fill | `ai.generativeFill` | OK | +| Remove object | `ai.removeObject` | OK | +| Harmonize | `ai.harmonize` | OK | +| Upscale | `ai.generativeUpscale` | OK | +| Generate image | `ai.generateImage` / server `generate_image` | OK | +| Expand | `ai.generativeExpand` | OK (content-bounds prefill, SW v760) | +| Remove background | `ai.removeBackground` | OK | +| Instruction edit / scene replace | server `ai_edit_image` | OK (HARNESS) | + +--- + +## 10. Gap list + +### Client-side (fix in this repo — PHOTOSHOP-owned) — **ALL FIXED** +1. ✅ **`fill.flood` not in the NL path** — added to `nl-edit.js OP_CATALOG` + + `localPlan` fill intent ("fill … with "; falls back to `selection.fill` + when a region is selected). +2. ✅ **`layer.flip` / `layer.rotate` not in the NL path** — added to + `nl-edit.js OP_CATALOG` + "this layer" intent (whole-image phrasing routes to + `document.flip`/`document.rotate`). +3. ✅ **`text.edit` not in the NL path** — added catalog entry + intent for font + size/colour ("make the text bigger/smaller", "make it red"). +4. ✅ **`document.extend` not in the NL path** — added catalog entry + intent for + "extend the canvas to 16:9" (derives target size from the document). +5. ✅ **`layers.align`** — added planner intent ("align … to the centre/left/…"). +6. ✅ **`layers.addStyle`** — added planner trigger ("drop shadow", "stroke", + "colour overlay"). +7. ✅ **Whole-image rotate/flip has no control op** — added `document.rotate` / + `document.flip` control ops (`galleryEditor.js`) and catalog/intent entries. +8. ✅ **`selection.copy/paste` intents** — added "copy the selection"/"paste". + +### Server-side (file for HARNESS in the passover) +1. `routes/editor_agent_routes.py OP_CATALOG` is missing ops the client runs: + `layer.flip`, `layer.rotate`, `text.edit`, `fill.flood`, `layers.align`, + `layers.rasterize`, `layers.mergeDown`, `layers.flatten`, `document.extend`, + `document.exportLayers`, `document.*` (list/duplicate/create/switch/openPsd), + `mask.*`, `selection.*` (polygon/feather/expand/contract/copy/paste/hasMask/ + face), `transform.*` are partly present. +2. `src/editor_style_guide.py` has no intents for: layer transform + (rotate/flip active layer), whole-document rotate/flip, text editing, align, + layer styles (drop shadow/stroke/overlay), flood fill, copy/paste, noise/ + pixelate/stylize/distort/render filters. + +## 11. Coverage summary + +Counting distinct user-facing capabilities in the toolbar + 12 menus + right-click +(~150 including the filter/effect long tail): + +- **Have a client op:** ~85% +- **Client op also in server OP_CATALOG:** ~75% +- **Planner can emit for natural phrasing:** ~72% (was ~55%; client-side + planner intents for fill, layer/whole-image transform, text edit, align, + layer styles, copy/paste and extend are now implemented) +- **Usable from Quick Edit end-to-end today:** **45/45 prompts (100%)** measured + live on build **0.20.45** with `tests/e2e/photo-editor/quickedit-coverage.js` + (`/tmp/opencode/qe-coverage45/quickedit-coverage.json`). HARNESS build 0.20.45 + (`4a6932a2`) added the 12 missing structural ops to the server `OP_CATALOG` + + style guide v5, so every audited prompt now plans to the exact op. + +The gap was dominated by the **planner**, not by missing client functionality; +that planner gap is now closed end-to-end (30/45 on 0.20.44 -> 45/45 on 0.20.45). + +## 12. Test prompts (~40) + +See `docs/QUICKEDIT_PROMPTS.md` and the runner +`tests/e2e/photo-editor/quickedit-coverage.js` for per-prompt pass/fail. + +### Live run (build 0.20.45) — 45/45 PASS + +All 45 prompts plan to the exact expected op. HARNESS 0.20.45 routes these +structural ops server-side: `fill.flood`, `text.edit`, `layers.align`, +`layer.rotate`/`layer.flip`, `document.rotate`/`document.flip`, +`selection.copy`/`selection.paste`, `layers.flatten`, `layers.mergeDown` (plus +the previously passing set). Results: +`/tmp/opencode/qe-coverage45/quickedit-coverage.json`. + +### Previous run (build 0.20.44) — 30/45 PASS + +Kept for provenance. Failures were all the server planner emitting a generic +`adjustments.apply`/`transform.apply` or a wrong op; ids 1, 2, 3, 4, 8, 9, 10, +11, 12, 13, 14, 19, 20, 40, 41. All now fixed in 0.20.45. + + +## Provenance + +Recon inventory captured from `static/js/editor/build/topbar.js`, +`static/js/editor/build/toolbar.js`, `static/js/editor/canvas-context-menu.js`, +`static/js/galleryEditor.js` (`installEditorControl`), `static/js/editor/nl-edit.js`, +`routes/editor_agent_routes.py`, and `src/editor_style_guide.py`. diff --git a/frontend/src/features/notes/ui/cardInteractions.js b/frontend/src/features/notes/ui/cardInteractions.js index 09e083c41..4160e5c1b 100644 --- a/frontend/src/features/notes/ui/cardInteractions.js +++ b/frontend/src/features/notes/ui/cardInteractions.js @@ -112,7 +112,7 @@ export function createCardInteractions({ if (state.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; // links open without toggling selection e.stopPropagation(); tapToEditOrSelect(card); }); diff --git a/frontend/src/features/notes/ui/editor.js b/frontend/src/features/notes/ui/editor.js index f5bdc7c9f..678b08804 100644 --- a/frontend/src/features/notes/ui/editor.js +++ b/frontend/src/features/notes/ui/editor.js @@ -51,7 +51,7 @@ export function createEditor({ - ${currentImageUrl && type !== 'draw' ? `
` : ''} + ${currentImageUrl && type !== 'draw' ? `
` : ''}
${type === 'note' ? `` @@ -297,7 +297,7 @@ export function createEditor({ form.querySelector('.note-form-image-wrap')?.remove(); const wrap = document.createElement('div'); wrap.className = 'note-form-image-wrap'; - wrap.innerHTML = ``; + wrap.innerHTML = ``; // 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. diff --git a/frontend/src/features/notes/ui/text.js b/frontend/src/features/notes/ui/text.js index 75ecbaf78..bb71cb690 100644 --- a/frontend/src/features/notes/ui/text.js +++ b/frontend/src/features/notes/ui/text.js @@ -31,7 +31,7 @@ export function createText({ url = url.slice(0, -1); } const href = url.startsWith('www.') ? `https://${url}` : url; - return `${url}` + (url !== m ? m.slice(url.length) : ''); + return `${url}` + (url !== m ? m.slice(url.length) : ''); }); } diff --git a/routes/editor_agent_routes.py b/routes/editor_agent_routes.py index dd63ade23..717c354e9 100644 --- a/routes/editor_agent_routes.py +++ b/routes/editor_agent_routes.py @@ -1203,19 +1203,64 @@ def _bridge_timeout(value: Any) -> float: def _is_admin(request: Request) -> bool: + """Return True only for authorized administrator browser sessions or auth-disabled single-user mode. + + Delegated credentials (bearer API tokens) are explicitly rejected: + per src/tool_security.py, bearer tokens are long-lived credentials handed to + third parties/scripts and must remain capped at non-admin policies even + when minted by an administrator. + """ + # 1. Delegated credentials (bearer API tokens) must NEVER receive admin metadata privileges + if getattr(request.state, "api_token", False): + return False + + # 2. In-process internal tool bypass (loopback agent tools) + try: + from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER + import secrets + hdr = request.headers.get(INTERNAL_TOOL_HEADER) + if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN): + return True + if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER: + return True + except Exception: + pass + + # 3. Explicitly auth-disabled single-user operation (AUTH_ENABLED=false) + try: + from src.auth_helpers import _auth_disabled + if _auth_disabled(): + return True + except Exception: + pass + + # 4. Middleware require_admin try: from core.middleware import require_admin require_admin(request) return True except Exception: - return False + pass + + # 5. Direct session/user check when request.state.current_user is set + try: + auth_mgr = getattr(request.app.state, "auth_manager", None) + if auth_mgr and getattr(auth_mgr, "is_configured", False): + from src.auth_helpers import get_current_user + user = get_current_user(request) or getattr(request.state, "current_user", None) + if user and getattr(auth_mgr, "is_admin", None) and auth_mgr.is_admin(user): + return True + except Exception: + pass + + return False def _user(request: Request) -> Optional[str]: - # These owner-scoped editor APIs have no delegated-token scope contract. - # Do not collapse all bearer users into the shared "api" pseudo-owner. + # Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner if getattr(request.state, "api_token", False): - raise HTTPException(403, "API tokens must use a scope-aware API route") + from src.auth_helpers import require_api_token_scope + return require_api_token_scope(request, "chat") from src.auth_helpers import get_current_user, _auth_disabled u = get_current_user(request) if u: @@ -1358,9 +1403,13 @@ def setup_editor_agent_routes() -> APIRouter: except Exception: raw = None _used = editor_agent_model.get_last_model() - model_used = (_used[0] if _used and _used[0] - else (agent_model or editor_agent_model.local_model())) parsed = _parse_plan(raw) + if _used and _used[0]: + model_used = _used[0] + elif parsed: + model_used = agent_model or editor_agent_model.local_model() + else: + model_used = "fallback" if agent_model else editor_agent_model.local_model() if isinstance(parsed, dict): parsed["agent_model"] = model_used if mode != "check": @@ -1634,9 +1683,14 @@ def setup_editor_agent_routes() -> APIRouter: except Exception: raw = None _used = editor_agent_model.get_last_model() - model_used = (_used[0] if _used and _used[0] - else (agent_model or editor_agent_model.local_model())) - plan = _parse_plan(raw) or _fallback_plan(prompt) + parsed = _parse_plan(raw) + if _used and _used[0]: + model_used = _used[0] + elif parsed: + model_used = agent_model or editor_agent_model.local_model() + else: + model_used = "fallback" if agent_model else editor_agent_model.local_model() + plan = parsed or _fallback_plan(prompt) _strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or [])) if _strengthened != plan.get("steps"): plan = {**plan, "steps": _strengthened, @@ -1723,6 +1777,12 @@ def setup_editor_agent_routes() -> APIRouter: "results": [], "agent_model": model_used, }) + needed = {"plan_edit"} + for step in steps: + op = step.get("op") + needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command") + if needed.intersection(disabled): + raise HTTPException(403, "Editor tool is disabled") results: List[Dict[str, Any]] = [] for step in steps: op = step.get("op") @@ -1865,14 +1925,19 @@ def setup_editor_agent_routes() -> APIRouter: # recognisably image-capable — the same test the request # router uses. image_endpoint = getattr(ep, "model_type", None) == "image" + endpoint_id = getattr(ep, "id", None) provider = getattr(ep, "name", None) if not provider: - provider = getattr(ep, "base_url", "") if is_admin else "image" + provider = ( + getattr(ep, "base_url", "") + if is_admin + else (f"Endpoint {endpoint_id}" if endpoint_id else "image") + ) provider = provider or "image" for name in _endpoint_enabled_models(ep): if not image_endpoint and not looks_like_image_generation_model(name): continue - key = (provider, name) + key = (str(endpoint_id or provider), name) if key in seen: continue seen.add(key) @@ -1881,7 +1946,7 @@ def setup_editor_agent_routes() -> APIRouter: "label": name, "kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate", "provider": provider, - "endpoint_id": getattr(ep, "id", None), + "endpoint_id": endpoint_id, } if is_admin: entry["base_url"] = getattr(ep, "base_url", "") or "" @@ -1896,11 +1961,12 @@ def setup_editor_agent_routes() -> APIRouter: async def editor_agent_models(request: Request) -> JSONResponse: """List the models the editor agent/planner can run on. - Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is + Each entry is ``{id, label, source, vision}`` where ``id`` is passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and ``source`` is ``local`` (the local Qwen3.5-9B vision server) or ``hosted`` (the configured default/utility chat model that the planner falls back to). + For non-admin users, private URLs and internal probe status are omitted. """ owner = _user(request) is_admin = _is_admin(request) @@ -1916,12 +1982,14 @@ def setup_editor_agent_routes() -> APIRouter: except Exception: logger.warning("editor agent-models lookup failed", exc_info=True) models = [] + probe_info = {} if is_admin: probe_info = editor_agent_model.probe_status() if not probe_info: editor_agent_model.startup_probe() probe_info = editor_agent_model.probe_status() + return JSONResponse({ "models": models, "probe": probe_info, diff --git a/routes/gallery/gallery_routes.py b/routes/gallery/gallery_routes.py index f3aa63e5e..690c22044 100644 --- a/routes/gallery/gallery_routes.py +++ b/routes/gallery/gallery_routes.py @@ -683,9 +683,15 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st configured.update(v for v in values if isinstance(v, str)) except (TypeError, ValueError): pass - if model not in configured or not looks_like_image_generation_model(model): - continue - if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target: + if model: + if model not in configured or not looks_like_image_generation_model(model): + continue + else: + if not any(looks_like_image_generation_model(v) for v in configured): + continue + ep_id = str(getattr(ep, "id", "") or "") + ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", "")) + if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target): if owner and getattr(ep, "owner", None) == owner: return ep if fallback is None: @@ -712,7 +718,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]: ) if not ok: raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}") - async with httpx.AsyncClient(timeout=60) as c2: + async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2: ir = await c2.get(url) if ir.status_code == 200: return base64.b64encode(ir.content).decode() @@ -1730,7 +1736,7 @@ def setup_gallery_routes() -> APIRouter: requested_base = (body.pop("_endpoint", "") or "").rstrip("/") # SSRF hardening: validate a client-supplied endpoint before any # outbound request (mirrors routes/embedding_routes.py). - if requested_base: + if requested_base and "://" in requested_base: from src.url_safety import check_outbound_url ok, reason = check_outbound_url( requested_base, @@ -2076,7 +2082,7 @@ def setup_gallery_routes() -> APIRouter: runs off the event loop (large images). """ requested_base = (body.pop("_endpoint", "") or "").rstrip("/") - if requested_base: + if requested_base and "://" in requested_base: from src.url_safety import check_outbound_url ok, reason = check_outbound_url( requested_base, @@ -2169,26 +2175,38 @@ def setup_gallery_routes() -> APIRouter: try: ep = None if endpoint_id: - ep = owner_filter( - db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id), - ModelEndpoint, user, - ).first() - if ep is None and requested_base: + ep = _visible_image_endpoint_for_base(db, endpoint_id, user) + if ep is None: + return {"available": False, "reason": "endpoint not found or disabled"} + elif requested_base: ep = _visible_image_endpoint_for_base(db, requested_base, user) - if ep is None: + if ep is None: + return {"available": False, "reason": "endpoint not found or disabled"} + else: ep = _first_visible_image_endpoint(db, user) if ep is None: return {"available": False, "reason": "no image endpoint configured"} base = ep.base_url.rstrip("/") finally: db.close() + + from src.url_safety import check_outbound_url + block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true" + ok, reason = check_outbound_url(base, block_private=block_private) + if not ok: + # CodeQL #1203: Never expose internal addresses, resolver errors or raw + # exception details to clients or logs. Use a stable diagnostic message + # without interpolating untrusted exception content or destination strings. + logger.warning("edit-progress endpoint rejected: outbound URL policy check failed") + return {"available": False, "reason": "endpoint URL rejected"} + if not base.endswith("/v1"): base += "/v1" # Dynamic path segment (validated token above), so not routed through # _join_checked_gallery_endpoint's exact-match whitelist. url = f"{base}/images/progress/{request_id}" try: - async with httpx.AsyncClient(timeout=10) as client: + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: r = await client.get(url) if r.status_code == 200: data = r.json() @@ -2226,7 +2244,7 @@ def setup_gallery_routes() -> APIRouter: # below, so validate it first (mirrors routes/embedding_routes.py). # Local-first means loopback/LAN is allowed by default; the cloud # metadata range and non-HTTP(S) schemes are always rejected. - if requested_base: + if requested_base and "://" in requested_base: from src.url_safety import check_outbound_url ok, reason = check_outbound_url( requested_base, @@ -2253,7 +2271,7 @@ def setup_gallery_routes() -> APIRouter: # so the outbound URL never depends directly on request-body input. db = SessionLocal() try: - ep = _visible_image_endpoint_for_base(db, requested_base, user) + ep = _visible_image_endpoint_for_base(db, requested_base, user, model) if not ep: raise HTTPException(403, "Choose a registered image endpoint") base = ep.base_url.rstrip("/") diff --git a/routes/mcp/mcp_routes.py b/routes/mcp/mcp_routes.py index 5d14ba8c3..5a8ae9c92 100644 --- a/routes/mcp/mcp_routes.py +++ b/routes/mcp/mcp_routes.py @@ -5,6 +5,7 @@ import os import uuid import urllib.parse import html +import secrets from pathlib import Path from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import RedirectResponse, HTMLResponse @@ -12,7 +13,7 @@ import logging import httpx from core.database import McpServer, SessionLocal -from core.middleware import require_admin +from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, require_admin from src.constants import DATA_DIR, MCP_OAUTH_DIR from src.mcp_manager import McpManager @@ -424,6 +425,11 @@ def setup_mcp_routes(mcp_manager: McpManager): Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]} """ require_admin(request) + internal_token = request.headers.get(INTERNAL_TOOL_HEADER) + if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN): + # Same boundary as POST /api/tools: an agent turn must not be able + # to re-enable MCP tools an admin switched off. + raise HTTPException(403, "Change MCP tool policy in Settings > Integrations, not from chat") db = SessionLocal() try: srv = db.query(McpServer).filter(McpServer.id == server_id).first() diff --git a/routes/model_routes.py b/routes/model_routes.py index 9749e0bbd..4535bbafe 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -7,6 +7,7 @@ import json import hashlib import ipaddress import socket +import secrets import time as _time import logging import threading @@ -3091,6 +3092,12 @@ def setup_model_routes(model_discovery): def update_tools(body: ToolsUpdate, request: Request): """Update which tools are disabled.""" require_admin(request) + from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN + internal_token = request.headers.get(INTERNAL_TOOL_HEADER) + if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN): + # Owner impersonation can carry an admin username, so check the + # authenticated transport rather than request.state.current_user. + raise HTTPException(403, "Change tool policy in Settings > Agent Tools, not from chat") settings = _load_settings() settings["disabled_tools"] = body.disabled _save_settings(settings) diff --git a/routes/upload_routes.py b/routes/upload_routes.py index 347da487e..92bbb84ea 100644 --- a/routes/upload_routes.py +++ b/routes/upload_routes.py @@ -25,11 +25,12 @@ from src.auth_helpers import effective_user, is_delegated_credential from src.owner_identity import auth_disabled, is_request_sentinel_owner from src.attachment_refs import attachment_refs_from_metadata from src.constants import GENERATED_IMAGES_DIR -from src.path_confinement import is_inside +from src.path_confinement import PathEscape, canonical_root, confine, is_inside from src.upload_handler import ( UploadCleanupSafetyError, count_recent_uploads, extract_upload_ids, + secure_filename, ) logger = logging.getLogger(__name__) @@ -153,30 +154,47 @@ def setup_upload_routes(upload_handler): def _upload_root() -> str: from src.constants import UPLOAD_DIR - return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR)) + return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR)) def _path_inside_upload_dir(path: str) -> bool: return is_inside(_upload_root(), path) def _resolve_upload_path(file_id: str) -> str: - from src.constants import UPLOAD_DIR - upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR) - direct = os.path.join(upload_root, file_id) + safe_id = secure_filename(os.path.basename(file_id)) + if not safe_id: + raise HTTPException(400, "Invalid file ID") + root = _upload_root() + + direct = os.path.normpath(os.path.join(root, safe_id)) if os.path.lexists(direct): - if not _path_inside_upload_dir(direct): + try: + canonical_direct = confine(root, direct, allow_root=False) + except (PathEscape, ValueError): raise HTTPException(403, "Access denied") - if os.path.isfile(direct): - return direct + if os.path.isfile(canonical_direct): + return canonical_direct raise HTTPException(404, "File not found") - for root, _dirs, files in os.walk(upload_root, followlinks=False): - if file_id not in files: - continue - path = os.path.join(root, file_id) - if not _path_inside_upload_dir(path): + info = _load_upload_info(safe_id) + stored_path = (info or {}).get("path") + if stored_path: + try: + canonical_stored = confine(root, stored_path, allow_root=False) + if os.path.isfile(canonical_stored): + return canonical_stored + except (PathEscape, ValueError): raise HTTPException(403, "Access denied") - if os.path.isfile(path): - return path + + for walk_root, _dirs, files in os.walk(root, followlinks=False): + if safe_id not in files: + continue + cand = os.path.normpath(os.path.join(walk_root, safe_id)) + try: + canonical_cand = confine(root, cand, allow_root=False) + except (PathEscape, ValueError): + raise HTTPException(403, "Access denied") + if os.path.isfile(canonical_cand): + return canonical_cand raise HTTPException(404, "File not found") raise HTTPException(404, "File not found") @@ -435,52 +453,79 @@ def setup_upload_routes(upload_handler): logger.error(f"Failed to get upload stats: {e}") raise HTTPException(500, "Failed to get upload statistics") - @router.get("/{file_id}") + @router.api_route("/{file_id}", methods=["GET", "HEAD"]) async def download_file(request: Request, file_id: str, thumb: int = 0): - """Serve an uploaded file by its ID. `?thumb=1` returns a small cached - JPEG thumbnail for images (used by chat attachment previews) so the - client isn't downloading the full-resolution photo just to show it tiny.""" + """Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns + a small cached JPEG thumbnail for images (used by chat attachment previews) + so the client isn't downloading the full-resolution photo just to show it tiny.""" if not upload_handler.validate_upload_id(file_id): raise HTTPException(400, "Invalid file ID") + safe_file_id = secure_filename(os.path.basename(file_id)) import mimetypes as _mt # Look up original filename and owner from uploads.json - original_name = file_id + original_name = safe_file_id # _load_upload_index() tolerates a missing/corrupt uploads.json (it falls # back to the .bak sibling, then to {}), so a truncated DB degrades to # "no metadata" instead of a 500 from an unhandled JSONDecodeError. db = upload_handler._load_upload_index() - info = next((fi for fi in db.values() if fi.get("id") == file_id), None) + info = next((fi for fi in db.values() if fi.get("id") == safe_file_id), None) if info: - original_name = info.get("name", file_id) + original_name = info.get("name", safe_file_id) file_owner = info.get("owner") if info else None _verify_upload_access(request, file_owner) - path = _resolve_upload_path(file_id) + upload_root = _upload_root() + path = _resolve_upload_path(safe_file_id) + try: + path = confine(upload_root, path, allow_root=False) + except (PathEscape, ValueError): + raise HTTPException(403, "Access denied") + if not path.startswith(upload_root + os.sep) or not _path_inside_upload_dir(path): + raise HTTPException(403, "Access denied") mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream" from fastapi.responses import FileResponse # Downscaled thumbnail for image previews — generated once and cached. + # Skip generation during HEAD probe to avoid loading/processing whole image. if thumb and mime.startswith("image/"): + thumb_dir = os.path.normpath(os.path.join(upload_root, ".thumbs")) + thumb_path = os.path.normpath(os.path.join(thumb_dir, safe_file_id + ".jpg")) try: - from PIL import Image, ImageOps - thumb_dir = os.path.join(_upload_root(), ".thumbs") - os.makedirs(thumb_dir, exist_ok=True) - thumb_path = os.path.join(thumb_dir, file_id + ".jpg") - if (not os.path.exists(thumb_path) - or os.path.getmtime(thumb_path) < os.path.getmtime(path)): - im = Image.open(path) - # iPhone / camera JPEGs encode rotation in EXIF rather than - # the pixel data. Browsers honour that on the original via - # image-orientation:from-image, but PIL strips EXIF when it - # saves the JPEG thumb, leaving the pixels sideways. Bake - # the rotation into the pixels before thumbnailing. - im = ImageOps.exif_transpose(im) - im.thumbnail((320, 320)) - if im.mode not in ("RGB", "L"): - im = im.convert("RGB") - im.save(thumb_path, "JPEG", quality=80) - return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS) - except Exception as e: - logger.warning(f"Thumbnail generation failed for {file_id}: {e}") - # Fall through to the full image. + thumb_path = confine(upload_root, thumb_path, allow_root=False) + except (PathEscape, ValueError): + raise HTTPException(403, "Access denied") + if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path): + raise HTTPException(403, "Access denied") + if request.method == "HEAD": + if (os.path.isfile(thumb_path) + and os.path.getmtime(thumb_path) >= os.path.getmtime(path)): + return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS) + # GET may generate a JPEG or fall back to the original on failure. + # Neither representation is known without decoding the image. HEAD + # must not advertise the original/stale file's headers as a thumb. + from fastapi.responses import Response + response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS) + del response.headers["content-length"] + return response + else: + try: + from PIL import Image, ImageOps + os.makedirs(thumb_dir, exist_ok=True) + if (not os.path.exists(thumb_path) + or os.path.getmtime(thumb_path) < os.path.getmtime(path)): + im = Image.open(path) + # iPhone / camera JPEGs encode rotation in EXIF rather than + # the pixel data. Browsers honour that on the original via + # image-orientation:from-image, but PIL strips EXIF when it + # saves the JPEG thumb, leaving the pixels sideways. Bake + # the rotation into the pixels before thumbnailing. + im = ImageOps.exif_transpose(im) + im.thumbnail((320, 320)) + if im.mode not in ("RGB", "L"): + im = im.convert("RGB") + im.save(thumb_path, "JPEG", quality=80) + return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS) + except Exception as e: + logger.warning(f"Thumbnail generation failed for {file_id}: {e}") + # Fall through to the full image. return FileResponse( path, media_type=mime, @@ -496,9 +541,10 @@ def setup_upload_routes(upload_handler): return next((fi for fi in db.values() if fi.get("id") == file_id), None) def _vision_cache_path(file_id: str) -> str: - cache_dir = os.path.join(_upload_root(), ".vision") + safe_id = secure_filename(os.path.basename(file_id)) + cache_dir = os.path.normpath(os.path.join(_upload_root(), ".vision")) os.makedirs(cache_dir, exist_ok=True) - return os.path.join(cache_dir, file_id + ".txt") + return os.path.normpath(os.path.join(cache_dir, safe_id + ".txt")) def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None: """Copy upload OCR/vision text onto the promoted gallery image row.""" diff --git a/scripts/check_brotli.py b/scripts/check_brotli.py new file mode 100644 index 000000000..3e8a52346 --- /dev/null +++ b/scripts/check_brotli.py @@ -0,0 +1,122 @@ +"""Fail image builds that select an unfixed Debian native Brotli decoder. + +Debian Node links libbrotlidec rather than bundling upstream Node's decoder. +Use Debian's backport revision, not BrotliDecoderVersion() or Python wheel +metadata, to establish the package floor for CVE-2026-111497: +https://security-tracker.debian.org/tracker/CVE-2026-111497 +""" + +import argparse +import ctypes +from pathlib import Path +import platform +import re +import subprocess +import sys + + +DEBIAN_FIXED = {"12": "1.0.9-2+deb12u1", "13": "1.1.0-2+deb13u1"} +DECODER_SONAME = "libbrotlidec.so.1" + + +def command(*args): + return subprocess.check_output(args, text=True).strip() + + +def check_package(): + release = platform.freedesktop_os_release() + floor = DEBIAN_FIXED.get(release.get("VERSION_ID")) + if release.get("ID") != "debian" or floor is None: + raise ValueError("Brotli package floor is not defined for this distribution") + record = command( + "dpkg-query", "-W", "-f=${Status}\n${Version}", "libbrotli1" + ).splitlines() + if len(record) != 2 or any(not value.strip() for value in record): + raise ValueError("invalid libbrotli1 package record: expected status and version") + status, version = record + if status != "install ok installed": + raise ValueError(f"libbrotli1 is not fully installed (status: {status})") + if subprocess.run( + ["dpkg", "--compare-versions", version, "ge", floor], check=False + ).returncode != 0: + raise ValueError(f"libbrotli1 {version} does not meet Debian's fixed floor {floor}") + owned = { + Path(line).resolve() + for line in command("dpkg-query", "-L", "libbrotli1").splitlines() + if Path(line).name == DECODER_SONAME + } + if not owned: + raise ValueError("libbrotli1 has no packaged native decoder") + return version, owned + + +def loaded_path(library): + # Inspect the library actually selected by the dynamic loader. An installed + # fixed package alone would not catch LD_LIBRARY_PATH or /usr/local shadows. + class DlInfo(ctypes.Structure): + _fields_ = [("filename", ctypes.c_char_p), ("base", ctypes.c_void_p), + ("symbol", ctypes.c_char_p), ("address", ctypes.c_void_p)] + + dladdr = ctypes.CDLL(None).dladdr + dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(DlInfo)] + dladdr.restype = ctypes.c_int + info = DlInfo() + if not dladdr(ctypes.cast(library.BrotliDecoderDecompress, ctypes.c_void_p), ctypes.byref(info)): + raise ValueError("cannot identify the loaded Brotli decoder") + return Path(info.filename.decode()).resolve() + + +def check_decoder(library): + # The fix adds error -17. Older decoders return INVALID here, including + # upstream 1.1.0 and 1.2.0. Fixed Debian 12 and 13 backports expose the same + # marker with different error-string prefixes. + # No malformed attacker-controlled stream is needed for this build check. + library.BrotliDecoderErrorString.argtypes = [ctypes.c_int] + library.BrotliDecoderErrorString.restype = ctypes.c_char_p + if library.BrotliDecoderErrorString(-17) not in (b"BLOCK_SWITCH", b"_ERROR_FORMAT_BLOCK_SWITCH"): + raise ValueError("native decoder lacks the CVE-2026-111497 BLOCK_SWITCH fix marker") + + library.BrotliDecoderDecompress.argtypes = [ + ctypes.c_size_t, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p + ] + library.BrotliDecoderDecompress.restype = ctypes.c_int + # A small, valid uncompressed Brotli stream must still decode correctly. + encoded = bytes.fromhex("0b058068656c6c6f20776f726c6403") + output = ctypes.create_string_buffer(11) + size = ctypes.c_size_t(len(output)) + result = library.BrotliDecoderDecompress(len(encoded), encoded, ctypes.byref(size), output) + if result != 1 or size.value != 11 or output.raw != b"hello world": + raise ValueError("native Brotli decoder failed the valid-stream control") + + +def check_node(node, decoder): + # ldd inspects the distro executable and its libnode dependency without + # executing JavaScript or relying on process.versions.brotli. + linked = command("ldd", str(node)) + match = re.search(r"libbrotlidec\.so\.1\s+=>\s+(/\S+)", linked) + if not match or Path(match[1]).resolve() != decoder: + raise ValueError(f"{node} does not select the checked Debian Brotli decoder") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--node", type=Path, help="also verify this distro Node executable's decoder") + args = parser.parse_args(argv) + try: + version, owned = check_package() + library = ctypes.CDLL(DECODER_SONAME) + decoder = loaded_path(library) + if decoder not in owned: + raise ValueError(f"selected decoder is outside libbrotli1: {decoder}") + check_decoder(library) + if args.node: + check_node(args.node, decoder) + except (ValueError, OSError, AttributeError, subprocess.CalledProcessError) as exc: + print(f"Brotli acceptance failed: {exc}", file=sys.stderr) + return 1 + print(f"Brotli accepted: Debian libbrotli1 {version}, decoder {decoder}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sanitize_trace_artifacts.py b/scripts/sanitize_trace_artifacts.py new file mode 100644 index 000000000..ea975b3b5 --- /dev/null +++ b/scripts/sanitize_trace_artifacts.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Sanitize Playwright trace archives to strip sensitive credentials and tokens. + +Structured, format-aware fail-closed security: +Replaces naive regex-based replacement with structured JSON / HAR parsing, +recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization, +safe source code masking, and strict fail-closed purge on corruption or error. +""" +from __future__ import annotations + +import json +import logging +import os +import re +import sys +import urllib.parse +import zipfile +from typing import Any, Dict, List, Set + +logger = logging.getLogger(__name__) + +BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico') + +SENSITIVE_KEY_PATTERN = re.compile( + r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)' +) + +SENSITIVE_PARAM_NAMES = frozenset({ + "token", "session", "auth", "password", "passwd", "key", "secret", "api_key", + "apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig", + "signature", "client_secret", "credential" +}) + +SENSITIVE_HEADER_NAMES = frozenset({ + "authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", + "api-key", "x-auth-token", "x-access-token" +}) + + +def sanitize_url(url: str) -> str: + """Sanitize query parameters and authority credentials in URLs.""" + if not isinstance(url, str) or not url.strip(): + return url + try: + parts = urllib.parse.urlsplit(url) + except Exception: + return url + + netloc = parts.netloc + if "@" in netloc: + userinfo, host = netloc.split("@", 1) + if ":" in userinfo: + user, _ = userinfo.split(":", 1) + netloc = f"{user}:[REDACTED]@{host}" + else: + netloc = f"[REDACTED]@{host}" + + query = parts.query + if query: + q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True) + sanitized_pairs = [] + for k, v in q_pairs: + k_lower = k.lower().replace("-", "_") + if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower): + sanitized_pairs.append((k, "[REDACTED]")) + else: + sanitized_pairs.append((k, v)) + query = urllib.parse.urlencode(sanitized_pairs) + + return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment)) + + +def sanitize_body_text(text: str) -> str: + """Sanitize request bodies formatted as JSON or URL-encoded form data.""" + if not isinstance(text, str) or not text.strip(): + return text + + # 1. Try JSON + trimmed = text.strip() + if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")): + try: + parsed = json.loads(trimmed) + sanitized = sanitize_structured_obj(parsed) + return json.dumps(sanitized, ensure_ascii=False) + except Exception: + pass + + # 2. Try URL-encoded form data + if "=" in trimmed and not trimmed.startswith("<"): + try: + pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True) + if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs): + out_pairs = [] + for k, v in pairs: + k_lower = k.lower().replace("-", "_") + if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower): + out_pairs.append((k, "[REDACTED]")) + else: + out_pairs.append((k, v)) + return urllib.parse.urlencode(out_pairs) + except Exception: + pass + + return text + + +def sanitize_structured_obj(obj: Any) -> Any: + """Recursively sanitize Python objects decoded from JSON or HAR payloads.""" + if isinstance(obj, dict): + new_dict: Dict[str, Any] = {} + + # Check for header entry with inverted or standard keys: {"name": ..., "value": ...} + name_val = obj.get("name") + val_val = obj.get("value") + if isinstance(name_val, str) and isinstance(val_val, str): + name_lower = name_val.lower().strip() + if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower): + for k, v in obj.items(): + if k == "value": + new_dict[k] = "[REDACTED]" + else: + new_dict[k] = sanitize_structured_obj(v) + return new_dict + + # Process standard dictionary keys + for k, v in obj.items(): + k_lower = str(k).lower().replace("-", "_") + + # Action parameters in Playwright traces (e.g. page.fill, page.type) + if k == "params" and isinstance(v, dict): + sanitized_params = sanitize_structured_obj(v) + # If this action involves fill, type, or sensitive inputs, redact value/text + if "value" in sanitized_params: + sanitized_params["value"] = "[REDACTED]" + if "text" in sanitized_params: + sanitized_params["text"] = "[REDACTED]" + new_dict[k] = sanitized_params + continue + + # DOM snapshot nodes + if k == "attributes" and isinstance(v, (dict, list)): + if isinstance(v, dict): + attr_dict = dict(v) + type_val = str(attr_dict.get("type") or "").lower() + if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower(): + if "value" in attr_dict: + attr_dict["value"] = "[REDACTED]" + new_dict[k] = attr_dict + continue + elif isinstance(v, list): + # List of attribute strings or pairs + new_list = [] + is_pw = any("password" in str(x).lower() for x in v) + for x in v: + if is_pw and isinstance(x, str) and not x.startswith("type="): + new_list.append("[REDACTED]") + else: + new_list.append(sanitize_structured_obj(x)) + new_dict[k] = new_list + continue + + # Request postData / text + if k in ("postData", "postDataText") and isinstance(v, str): + new_dict[k] = sanitize_body_text(v) + continue + + # URL fields + if k == "url" and isinstance(v, str): + new_dict[k] = sanitize_url(v) + continue + + # Cookie arrays + if k == "cookies" and isinstance(v, list): + sanitized_cookies = [] + for c in v: + if isinstance(c, dict): + c_copy = dict(c) + if "value" in c_copy: + c_copy["value"] = "[REDACTED]" + sanitized_cookies.append(c_copy) + else: + sanitized_cookies.append(sanitize_structured_obj(c)) + new_dict[k] = sanitized_cookies + continue + + # Direct sensitive key matches + if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str): + new_dict[k] = "[REDACTED]" + continue + + new_dict[k] = sanitize_structured_obj(v) + + return new_dict + + elif isinstance(obj, list): + return [sanitize_structured_obj(item) for item in obj] + + elif isinstance(obj, str): + # Check for inline credentials/tokens + if obj.startswith("Bearer ") or obj.startswith("bearer "): + return "Bearer [REDACTED]" + if obj.startswith("Basic ") or obj.startswith("basic "): + return "Basic [REDACTED]" + return obj + + return obj + + +def sanitize_jsonl_content(data: bytes) -> bytes: + """Sanitize newline-delimited JSON Lines (.trace, .network, .stacks).""" + lines = data.split(b"\n") + out_lines = [] + for line in lines: + if not line.strip(): + out_lines.append(line) + continue + try: + parsed = json.loads(line.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + # Not valid JSON, keep as-is + out_lines.append(line) + continue + sanitized = sanitize_structured_obj(parsed) + out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8")) + return b"\n".join(out_lines) + + +def sanitize_trace_archive(zp: str) -> bool: + """Sanitize a single zip archive in a strict fail-closed manner. + + If the archive is malformed, partial, unreadable, or encounters an exception + during processing, the archive is permanently deleted so that raw traces + cannot leak. + Returns True if successfully sanitized, False if deleted due to error. + """ + if not os.path.exists(zp): + return False + + temp_zp = zp + ".tmp" + try: + if os.path.getsize(zp) == 0: + raise zipfile.BadZipFile("Empty archive") + + with zipfile.ZipFile(zp, "r") as zin: + infolist = zin.infolist() + if not infolist: + raise zipfile.BadZipFile("Archive has no entries") + + corrupt_file = zin.testzip() + if corrupt_file is not None: + raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}") + + with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for item in infolist: + filename_lower = item.filename.lower() + + # 1. Binary image screenshots/diagnostics: preserve as-is + if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS): + content = zin.read(item.filename) + zout.writestr(item, content) + continue + + # 2. Embedded test source code (e.g. resources/src@... or .js/.ts): + # Mask safely without syntax corruption! + if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")): + safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n" + zout.writestr(item, safe_placeholder) + continue + + # 3. JSON Lines and structured JSON/HAR traces + content = zin.read(item.filename) + if filename_lower.endswith((".trace", ".network", ".stacks")): + sanitized_content = sanitize_jsonl_content(content) + zout.writestr(item, sanitized_content) + elif filename_lower.endswith((".json", ".har")): + try: + parsed = json.loads(content.decode("utf-8")) + sanitized = sanitize_structured_obj(parsed) + zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8")) + except Exception: + # If invalid JSON, treat as text line-by-line + zout.writestr(item, sanitize_jsonl_content(content)) + else: + # Unknown binary or text member: exclude to fail closed + continue + + # Atomic replace + os.replace(temp_zp, zp) + return True + + except Exception as exc: + logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc) + for path_to_remove in (temp_zp, zp): + if os.path.exists(path_to_remove): + try: + os.remove(path_to_remove) + except OSError as remove_err: + logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err) + return False + + +def sanitize_directory(results_dir: str) -> int: + """Walk results_dir and sanitize every .zip archive fail-closed. + + Returns the number of archives processed. Exits with 1 if any archive fails. + """ + if not os.path.exists(results_dir): + return 0 + + count = 0 + all_ok = True + for root, _, files in os.walk(results_dir): + for f in files: + if f.endswith(".zip"): + zp = os.path.join(root, f) + ok = sanitize_trace_archive(zp) + if not ok: + all_ok = False + count += 1 + + if not all_ok: + sys.exit(1) + return count + + +def main() -> None: + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + target_dir = sys.argv[1] + sanitize_directory(target_dir) + + +if __name__ == "__main__": + main() diff --git a/src/agent_loop.py b/src/agent_loop.py index 2556ec27c..3b45c77d5 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -2834,7 +2834,7 @@ def _parse_qwen_explicit_admin_request(text: str) -> Optional[tuple[str, str]]: if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q): return "manage_settings", json.dumps({"action": "list_tools"}) if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q): - return "manage_settings", json.dumps({"action": "enable_tool", "tool": "images"}) + return "manage_settings", json.dumps({"action": "list_tools"}) if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q): return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"}) if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q): @@ -8057,7 +8057,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g. "manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}", "manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.", "manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"\", \"search\": \"...\"}. `list` returns rows like `[query](#research-) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.", - "manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool|enable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`.", + "manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`. You cannot re-enable a disabled tool: that is done by the user in Settings > Agent Tools.", "manage_notes": """\ ```manage_notes {"action": "add", "title": "", "due_date": ""} diff --git a/src/agent_tools/admin_tools.py b/src/agent_tools/admin_tools.py index f0a7662c2..419b7f742 100644 --- a/src/agent_tools/admin_tools.py +++ b/src/agent_tools/admin_tools.py @@ -585,7 +585,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict: return _ALIASES_SET.get(k2, (k or "").strip()) def _is_managed_key(key): - return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS + # Tool policy is not a general setting, even if a future default + # is added: resetting it must never re-enable disabled tools. + return key != "disabled_tools" and key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS _ENUMS = { "image_quality": ["low", "medium", "high"], @@ -713,13 +715,35 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict: save_settings(s) return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0} - elif action in ("disable_tool", "enable_tool", "list_tools"): + elif action == "enable_tool": + # The global disabled_tools denylist is the one hard permission + # boundary the Settings -> Agent Tools panel offers admins. It + # must only ever be *narrowed* from inside an agent turn: a + # model that can call enable_tool can undo the boundary itself, + # and any web page, email, or document the agent reads can ask + # it to (prompt injection re-enabling bash/email/etc.). Widening + # the tool set stays a human action in the Settings panel. + tool_name = (args.get("tool") or args.get("name") or "").strip().lower() + return { + "error": ( + f"Re-enabling tools is not available from chat" + f"{f' (requested: {tool_name})' if tool_name else ''}. " + "Disabled tools are an admin policy: ask the user to turn the tool " + "back on in Settings > Agent Tools. You can still disable tools " + "(disable_tool) or list the current state (list_tools)." + ), + "exit_code": 1, + } + + elif action in ("disable_tool", "list_tools"): # Tool-toggle actions. These edit settings.json:disabled_tools # (the global list read on every chat request) rather than # prefs.json. Friendly aliases accepted: "shell" -> "bash", # "search" -> "web_search", "browser" -> browser tools, # "documents" -> the document tool set, "memory" -> - # manage_memory, etc. + # manage_memory, etc. Only disable_tool mutates: see the + # enable_tool branch above for why the agent can never widen + # the denylist. from src.settings import get_setting, save_settings, load_settings _ALIASES = { "shell": ["bash"], @@ -753,7 +777,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict: "response": ( f"Currently disabled: {', '.join(current) if current else '(none)'}.\n" "Common toggles: shell (bash), search (web_search), browser, documents, " - "memory, skills, images, tasks, notes, calendar, email." + "memory, skills, images, tasks, notes, calendar, email. " + "To re-enable a tool, open Settings > Agent Tools." ), "disabled": list(current), "exit_code": 0, @@ -767,22 +792,19 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict: settings = load_settings() current = list(settings.get("disabled_tools") or []) before = set(current) - if action == "disable_tool": - for t in targets: - if t not in current: - current.append(t) - else: # enable_tool - current = [t for t in current if t not in targets] + for t in targets: + if t not in current: + current.append(t) after = set(current) settings["disabled_tools"] = current save_settings(settings) - verb = "Disabled" if action == "disable_tool" else "Enabled" changed = sorted(after.symmetric_difference(before)) return { "response": ( - f"{verb} {tool_name} ({', '.join(targets)}). " - f"Now disabled: {', '.join(current) if current else '(none)'}." + f"Disabled {tool_name} ({', '.join(targets)}). " + f"Now disabled: {', '.join(current) if current else '(none)'}. " + "Re-enabling is done by the user in Settings > Agent Tools." ), "changed": changed, "disabled": list(current), diff --git a/src/editor_agent_model.py b/src/editor_agent_model.py index 138cf27bc..7c9bdf98a 100644 --- a/src/editor_agent_model.py +++ b/src/editor_agent_model.py @@ -339,6 +339,229 @@ async def self_check( return out +def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]: + """Resolve an explicit agent_model override under server authority. + + Guarantees: + - Client-provided raw URLs never determine arbitrary outbound network destinations. + - Registered endpoint IDs resolve only to enabled endpoints visible to the owner. + - Shared endpoints (owner IS NULL) remain visible to authorized users. + - Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None). + - Admin privileges do not allow arbitrary unconfigured destinations. + """ + raw = str(override or "").strip() + if not raw: + return None + + if "::" in raw: + target, requested_model = raw.split("::", 1) + target = target.strip() + requested_model = requested_model.strip() + else: + target = "" + requested_model = raw + + # 1. Explicit local selection or matching a configured local base + if target == "local": + bases = local_bases() + if not bases: + return None + return { + "base": bases[0], + "model": requested_model or local_model(), + "headers": None, + "source": "override", + } + for lb in local_bases(): + if target and target.rstrip("/") == lb.rstrip("/"): + return { + "base": lb, + "model": requested_model or local_model(), + "headers": None, + "source": "override", + } + + # 2. Explicit hosted fallback selection + try: + from src.endpoint_resolver import resolve_endpoint + + for prefix in ("utility", "default"): + try: + url, model, headers = resolve_endpoint(prefix, owner=owner) + except Exception: + continue + if url and model: + if target in ("default", "hosted"): + return { + "base": url, + "model": requested_model or model, + "headers": headers, + "source": "hosted", + } + if target and target.rstrip("/") == url.rstrip("/"): + if not requested_model or requested_model == model: + return { + "base": url, + "model": requested_model or model, + "headers": headers, + "source": "hosted", + } + except Exception: + pass + if target in ("default", "hosted"): + return None + + # 3. Bare model id without endpoint prefix + if not target: + if requested_model == "local": + bases = local_bases() + return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None + if requested_model in ("default", "hosted"): + return _resolve_override_candidate("default::", owner) + + # Exact match with local model + if requested_model == local_model(): + bases = local_bases() + if bases: + return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} + + # Exact match with hosted default/utility + try: + from src.endpoint_resolver import resolve_endpoint + + for prefix in ("utility", "default"): + try: + url, model, headers = resolve_endpoint(prefix, owner=owner) + except Exception: + continue + if url and model and model == requested_model: + return {"base": url, "model": model, "headers": headers, "source": "hosted"} + except Exception: + pass + + # Check registered database endpoints for this bare model + try: + from core.database import SessionLocal, ModelEndpoint + from src.auth_helpers import owner_filter, _auth_disabled + from src.endpoint_resolver import ( + resolve_endpoint_runtime, + build_chat_url, + build_headers, + _endpoint_enabled_models, + _endpoint_hidden_models, + ) + + db = SessionLocal() + try: + if not owner and not _auth_disabled(): + q = db.query(ModelEndpoint).filter( + ModelEndpoint.is_enabled == True, + ModelEndpoint.owner == None, + ) + else: + q = owner_filter( + db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True), + ModelEndpoint, + owner, + ) + matching_eps = [] + for ep in q.all(): + enabled_models = _endpoint_enabled_models(ep) + hidden_models = _endpoint_hidden_models(ep) + if requested_model in enabled_models and requested_model not in hidden_models: + matching_eps.append(ep) + + # Avoid model ID collision across providers: only accept if unique + if len(matching_eps) == 1: + ep = matching_eps[0] + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + return { + "base": build_chat_url(base), + "model": requested_model, + "headers": build_headers(api_key, base), + "source": "registered", + "endpoint_id": ep.id, + } + finally: + db.close() + except Exception: + pass + return None + + # 4. Target is an endpoint_id or a registered legacy endpoint URL + try: + from core.database import SessionLocal, ModelEndpoint + from src.auth_helpers import owner_filter, _auth_disabled + from src.endpoint_resolver import ( + resolve_endpoint_runtime, + build_chat_url, + build_headers, + _endpoint_enabled_models, + _endpoint_hidden_models, + _first_chat_model, + ) + + db = SessionLocal() + try: + if not owner and not _auth_disabled(): + q = db.query(ModelEndpoint).filter( + ModelEndpoint.is_enabled == True, + ModelEndpoint.owner == None, + ) + else: + q = owner_filter( + db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True), + ModelEndpoint, + owner, + ) + # Lookup by ID + ep = q.filter(ModelEndpoint.id == target).first() + + # If not found by ID, match legacy registered base URL (strictly registered rows only) + if ep is None and ("://" in target or target.startswith("http")): + target_norm = target.rstrip("/") + for cand_ep in q.all(): + cand_base = (cand_ep.base_url or "").rstrip("/") + if ( + cand_base == target_norm + or cand_base + "/v1" == target_norm + or cand_base == target_norm + "/v1" + ): + ep = cand_ep + break + + if ep is None: + # Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed! + return None + + enabled_models = _endpoint_enabled_models(ep) + hidden_models = _endpoint_hidden_models(ep) + if requested_model: + if requested_model in hidden_models: + return None + if enabled_models and requested_model not in enabled_models: + # Model not served by this endpoint -> fail closed against forged model IDs + return None + model = requested_model + else: + model = _first_chat_model(enabled_models) + if not model: + return None + + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + return { + "base": build_chat_url(base), + "model": model, + "headers": build_headers(api_key, base), + "source": "registered", + "endpoint_id": ep.id, + } + finally: + db.close() + except Exception: + return None + + def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]: cands: List[Dict[str, Any]] = [] for base in local_bases(): @@ -357,16 +580,13 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, except Exception: pass if override: - base, model = _split_override(override) - # A request may choose a configured endpoint, never introduce a URL. - # Prefer an exact model match (including hosted credentials) over the - # first local endpoint when a bare model id is supplied. - matching = [c for c in cands if not base or str(c["base"]).rstrip("/") == base.rstrip("/")] - selected = next((c for c in matching if c["model"] == model), None) - if selected is None and model and matching and matching[0]["source"] == "local": - selected = {**matching[0], "model": model} - if selected is not None: - cands.insert(0, {**selected, "source": "override"}) + cand = _resolve_override_candidate(override, owner) + if cand is not None: + cands.insert(0, cand) + return cands + # Unauthorized, invalid, foreign, disabled, or forged override: + # Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback. + return [] return cands @@ -438,9 +658,11 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str "source": "local", "vision": True, "base_url": base, + "endpoint_id": "local", }) try: from src.endpoint_resolver import resolve_endpoint + from src.settings import get_user_setting, load_settings for prefix in ("utility", "default"): try: @@ -451,12 +673,19 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str key = ("hosted", model) if key not in seen: seen.add(key) + ep_id = None + try: + settings = load_settings() + ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None + except Exception: + pass models.append({ "id": model, "label": model, "source": "hosted", "vision": None, "base_url": url, + "endpoint_id": ep_id or prefix, }) break except Exception: diff --git a/src/llm_core.py b/src/llm_core.py index c56346936..bde17741e 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -1556,8 +1556,9 @@ def _build_chatgpt_responses_payload( "stream": stream, "store": False, } - if not _restricts_temperature(model): - payload["temperature"] = temperature + # ChatGPT plan Responses requests reject temperature for every model. + # The common caller still passes its preset value, but this provider must + # omit the field even for model names outside the known reasoning families. # ChatGPT Subscription Codex API does not support max_output_tokens — # passing it returns HTTP 400 "Unsupported parameter: max_output_tokens". # Do not include it in the payload. diff --git a/src/tool_capabilities.py b/src/tool_capabilities.py index 966937655..c7a17da80 100644 --- a/src/tool_capabilities.py +++ b/src/tool_capabilities.py @@ -410,7 +410,7 @@ _PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType( "manage_endpoints": frozenset({"add", "delete", "enable", "disable"}), "manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}), "manage_settings": frozenset( - {"set", "delete", "reset", "disable_tool", "enable_tool"} + {"set", "delete", "reset", "disable_tool"} ), "manage_tokens": frozenset({"create", "delete"}), "manage_webhooks": frozenset({"add", "delete", "enable", "disable"}), diff --git a/src/tool_index.py b/src/tool_index.py index 483cddbab..e0e4c8fb6 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -145,7 +145,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "manage_tokens": "API token management: list, create, or delete API access tokens.", "manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset= + limit= to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.", "manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.", - "manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also toggles tools on/off (disable_tool/enable_tool/list_tools). Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.", + "manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also turns tools off globally (disable_tool) and lists disabled ones (list_tools); re-enabling is only done by the user in Settings > Agent Tools. Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.", "create_session": "Create a new chat with a name and model.", "list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).", "send_to_session": "Send a message to another chat. Cross-chat communication.", diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 1d583a33b..6468f4f45 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -1292,14 +1292,14 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "manage_settings", - "description": "Manage user preferences and settings. Use `disable_tool`/`enable_tool`/`list_tools` to turn individual tools on or off globally (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Use list/get/set/delete for free-form preferences.", + "description": "Manage user preferences and settings. Use `disable_tool`/`list_tools` to turn individual tools off globally or list what is disabled (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Disabled tools can only be re-enabled by the user in Settings > Agent Tools, never from chat. Use list/get/set/delete for free-form preferences.", "parameters": { "type": "object", "properties": { - "action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "enable_tool", "list_tools"]}, + "action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "list_tools"]}, "key": {"type": "string", "description": "Setting key (for get/set/delete)"}, "value": {"description": "Setting value (for set) — can be string, number, boolean, or object"}, - "tool": {"type": "string", "description": "Tool name to disable/enable (for disable_tool/enable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."} + "tool": {"type": "string", "description": "Tool name to disable (for disable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."} }, "required": ["action"] } diff --git a/src/tools/system.py b/src/tools/system.py index 9b0e6d1bb..09ca1fd41 100644 --- a/src/tools/system.py +++ b/src/tools/system.py @@ -769,6 +769,8 @@ _APP_API_BLOCKLIST_PREFIXES = ( # {"tasks": []} to /api/cookbook/state, which overwrote the whole file. # Use dedicated tools or UI flows instead. _APP_API_BLOCKLIST_METHOD_PATH = ( + ("POST", "/api/tools"), # global tool policy is changed by a human in Settings + ("PATCH", "/api/mcp/servers"), # per-server MCP tool policy and server toggles, same reason ("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool ("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead ("DELETE", "/api/cookbook/state"), @@ -893,6 +895,10 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict: if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"): return {"error": f"Unsupported method: {method}", "exit_code": 1} if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH): + if path.startswith("/api/tools"): + return {"error": "Tool policy cannot be replaced from chat. Re-enable tools in Settings > Agent Tools; use manage_settings to list or disable them.", "exit_code": 1} + if path.startswith("/api/mcp/servers"): + return {"error": "MCP server and tool policy cannot be changed from chat. Ask the user to change it in Settings > Integrations.", "exit_code": 1} if "/api/email/accounts" in path: return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1} if "/api/cookbook/packages/install" in path: diff --git a/static/css/00-tokens.css b/static/css/00-tokens.css index 1105ba70c..b43865ac3 100644 --- a/static/css/00-tokens.css +++ b/static/css/00-tokens.css @@ -155,11 +155,34 @@ 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'); } - /* 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'); } +@font-face { font-family: 'Space Grotesk'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Space Grotesk'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Bold.woff2') format('woff2'); } +@font-face { font-family: 'Fraunces'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Fraunces'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Bold.woff2') format('woff2'); } +@font-face { font-family: 'Syne'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Syne'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Bold.woff2') format('woff2'); } +@font-face { font-family: 'Bungee'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Bungee-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Instrument Serif'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/InstrumentSerif-Regular.woff2') format('woff2'); } + /* Code block baseline */ pre, code, .hljs { font-size: 11px; diff --git a/static/css/01-agent-chat.css b/static/css/01-agent-chat.css index 506d04e81..9a5a836cb 100644 --- a/static/css/01-agent-chat.css +++ b/static/css/01-agent-chat.css @@ -315,7 +315,7 @@ body.bg-pattern-ascii-fireflies { width: 240px; background: var(--sidebar-bg, var(--panel)); border-right: 1px solid var(--border); - transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease; + transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease, visibility 0.25s; display: flex; flex-direction: column; flex-shrink: 0; @@ -361,12 +361,23 @@ body.bg-pattern-ascii-fireflies { /* !important so it beats the inline width init.js restores from storage — otherwise the width never changes and only opacity animates, making the collapse look instant. With this, width animates from the inline value - down to 0 via the .sidebar width transition. */ + down to 0 via the .sidebar width transition. + display overrides the global .hidden utility, which would otherwise + remove the box before any transition can run. visibility (transitioned + on .sidebar) flips to hidden only once the collapse has finished, + taking the closed sidebar out of the tab order. */ + display: flex; width: 0 !important; padding: 0 !important; border: none; overflow: hidden; opacity: 0; + visibility: hidden; + pointer-events: none; + } + /* IDs so this also beats the mobile drawer's !important transition. */ + @media (prefers-reduced-motion: reduce) { + #sidebar, #icon-rail { transition: none !important; } } /* ===== Sidebar User Bar ===== */ .sidebar-user-bar { @@ -449,10 +460,6 @@ body.bg-pattern-ascii-fireflies { } - .sidebar.hidden .sidebar-user-bar { - display: none; - } - /* Sticky sidebar header — logo, never scrolls */ .sidebar-header { display: flex; @@ -673,6 +680,24 @@ body.bg-pattern-ascii-fireflies { margin-bottom: calc(-1 * var(--safe-bottom)); padding-bottom: calc(8px + var(--safe-bottom)); } + /* Tuck the rail under the sidebar (which paints above it) and slide it + out as the sidebar collapses, so the combined width eases instead of + jumping by the rail's 48px when the two swap. */ + .icon-rail, + .icon-rail.right-side { + margin-inline-end: -48px; + transition: margin-inline-end 0.25s ease, display 0.25s allow-discrete; + } + body.sidebar-collapsed:not(.hamburger-only) .icon-rail, + html.ody-sidebar-mini .icon-rail { + margin-inline-end: 0; + } + @starting-style { + body.sidebar-collapsed:not(.hamburger-only) .icon-rail, + html.ody-sidebar-mini .icon-rail { + margin-inline-end: -48px; + } + } } .icon-rail-divider { width: 24px; @@ -5116,7 +5141,7 @@ body.bg-pattern-ascii-fireflies { width: 80% !important; max-width: 340px; box-shadow: 4px 0 20px rgba(0,0,0,0.5); - transition: transform 0.25s ease, opacity 0.25s ease !important; + transition: transform 0.25s ease, opacity 0.25s ease, visibility 0.25s !important; opacity: 1 !important; overflow: visible !important; /* Force opaque background — Firefox mobile renders the @@ -5147,6 +5172,7 @@ body.bg-pattern-ascii-fireflies { } html.ody-sidebar-off .sidebar, html.ody-mobile-startup-sidebar-hidden .sidebar { + width: 80% !important; transform: translateX(-100%) !important; pointer-events: none !important; opacity: 0 !important; @@ -7230,6 +7256,39 @@ body.bg-pattern-ascii-fireflies { text-align: center; } + /* Inline assistant markdown images (`![alt](url)`) — rendered by + static/js/markdown.js as a.clickable wrapper. */ + a.chat-image { + display: inline-block; + max-width: 100%; + margin: 0.35rem 0; + } + a.chat-image img.chat-inline-image { + display: block; + max-width: 100%; + height: auto; + border-radius: 8px; + cursor: zoom-in; + } + a.chat-image .chat-image-fallback { + display: none; + font-size: 0.8rem; + color: var(--color-muted, #94a3b8); + word-break: break-all; + padding: 0.5rem 0.75rem; + border: 1px dashed var(--border, #475569); + border-radius: 8px; + background: var(--panel, #202428); + } + a.chat-image.image-broken img.chat-inline-image { + display: none; + } + a.chat-image.image-broken .chat-image-fallback { + display: block; + text-decoration: underline; + cursor: pointer; + } + /* Setup wizard */ .setup-wizard { padding: 16px; max-width: 500px; } .setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); } diff --git a/static/css/03-agent-chat.css b/static/css/03-agent-chat.css index 0e8c95f91..c245899c2 100644 --- a/static/css/03-agent-chat.css +++ b/static/css/03-agent-chat.css @@ -22,6 +22,20 @@ /* Session items */ .session-item { position: relative; } + +/* Title fade: the server auto-renames a chat shortly after its first + prompt, which re-renders the sidebar. Fade the new title in so the + change reads as smooth rather than a snap. */ +.session-item .grow.session-title-updated { + animation: sessionTitleFade 0.5s ease; +} +@keyframes sessionTitleFade { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } +} +@media (prefers-reduced-motion: reduce) { + .session-item .grow.session-title-updated { animation: none; } +} .text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .session-menu-btn { padding: 0 2px !important; min-width: 20px; height: 20px; display: inline-flex !important; align-items: center; justify-content: center; background: none !important; border-color: transparent !important; } .session-menu-btn:hover { background: none !important; border-color: transparent !important; } diff --git a/static/css/04-memory.css b/static/css/04-memory.css index 71b2537bd..67cb7ca16 100644 --- a/static/css/04-memory.css +++ b/static/css/04-memory.css @@ -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; } diff --git a/static/css/13-image-editor.css b/static/css/13-image-editor.css index 1fb216bc2..dfa8312e2 100644 --- a/static/css/13-image-editor.css +++ b/static/css/13-image-editor.css @@ -103,6 +103,559 @@ margin: 0 4px; } +/* Contextual tool options bar (between topbar and editor body) */ +.ge-options-bar { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; + flex-wrap: nowrap; + row-gap: 4px; + height: 44px; + min-height: 44px; + max-height: 44px; + padding: 4px 10px; + box-sizing: border-box; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 92%, var(--fg) 8%); + position: relative; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +/* A wand "More" dropdown must escape the bar's scroll clip while open. */ +.ge-options-bar:has(#ge-wand-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-brush-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-lasso-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-sam-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-quickselect-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-objectselect-more-menu:not([hidden])) { overflow: visible; } +.ge-options-tool { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + font-size: 11px; + font-weight: 600; + color: color-mix(in srgb, var(--fg) 85%, transparent); +} +.ge-options-tool-icon { + display: inline-flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; +} +.ge-options-sep { + width: 1px; + height: 20px; + background: var(--border); + flex: 0 0 auto; +} +.ge-options-body { + display: flex; + align-items: center; + gap: 14px; + /* Shrink to content instead of growing: an empty mirror body otherwise + expands and leaves a large blank gap before the relocated tool controls, + pushing them away from the left edge of the strip. */ + flex: 0 1 auto; + min-width: 0; +} +/* Tool options moved out of the dock into the options bar (Photoshop): + the real controls flow inline on the SAME single thin row, horizontally + scrollable when they do not fit. Section headings/hints are dropped in + this compact strip; the authoritative sliders mirror the contextual + strip, so duplicated ranges are hidden. */ +.ge-options-bar .ge-controls.ge-controls--in-options { + /* Grow to fill the strip so the selection action row can be pinned to the + right edge of the screen. */ + flex: 1 1 auto; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 14px; + padding: 0; + margin: 0; + border: 0; + min-width: 0; + max-height: none; + overflow: visible; +} +.ge-controls--in-options .ge-section-title, +.ge-controls--in-options .ge-section-hint { display: none; } +/* The "rembg not installed" notice would otherwise be a two-line block and + double the strip height; keep the install action, drop the essay. */ +.ge-controls--in-options #ge-rembg-dep-missing { + padding: 0; + border: 0; + background: none; + gap: 8px; +} +.ge-controls--in-options #ge-rembg-dep-missing .ge-dep-notice-text { display: none; } +.ge-controls--in-options > section, +.ge-controls--in-options > div { flex: 0 0 auto; } +/* Lay out every visible tool section as one horizontal strip. Hidden + sections carry an inline display:none and are left untouched; visible + ones must override their inline display:block, hence !important. */ +.ge-controls--in-options > div:not([style*="display:none"]):not([style*="display: none"]) { + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + align-items: center; + gap: 12px; + /* Let the active tool section span the strip so its action row can be + pushed to the right edge. */ + flex: 1 1 auto; +} +/* When transform section is active in controls, suppress any underlying tool section + so transform controls (Cancel, Apply, X, Y, W, H) have the full width and are not + overlapped or crowded by tool options (e.g. marquee invert/erase buttons). */ +.ge-controls.ge-transform-active > div:not(#ge-transform-section), +.ge-controls:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section), +.ge-controls--in-options:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section) { + display: none !important; +} +/* The Text tool's option set is too wide for the thin strip: let the bar grow + and wrap onto extra rows instead of clipping the trailing fields. */ +.ge-options-bar.ge-options-tall { + height: auto; + max-height: none; + flex-wrap: wrap; + align-items: center; + overflow-y: visible; + row-gap: 4px; + padding-top: 6px; + padding-bottom: 6px; +} +/* The active tool's name already sits in the rail; on the tall (Text) strip + its label would claim a whole line and shove the controls down. */ +.ge-options-bar.ge-options-tall .ge-options-tool { display: none; } +.ge-options-bar.ge-options-tall .ge-controls--in-options { + flex: 1 1 auto; + flex-wrap: wrap !important; + row-gap: 4px; +} +.ge-options-bar.ge-options-tall .ge-controls--in-options > div:not([style*="display:none"]):not([style*="display: none"]) { + flex-wrap: wrap !important; + row-gap: 4px; +} +/* The Text section's own field groups must wrap onto their own rows too, + otherwise the trailing format/Warp fields run past the right edge. */ +.ge-options-bar.ge-options-tall .ge-controls--in-options .ge-text-section { + display: flex !important; + flex-wrap: wrap !important; + align-items: flex-end; + gap: 8px; + width: 100%; +} +.ge-options-bar.ge-options-tall .ge-controls--in-options .ge-text-section > * { + flex: 0 0 auto; +} +.ge-options-bar.ge-options-tall .ge-controls--in-options .ge-text-section > textarea { + flex: 1 1 240px; + min-width: 240px; +} + +/* The brush sliders (Opacity/Flow/Softness) are a touch tight against the + strip width once the Color/Size/preset controls are inline; trim the + gutters so the whole brush strip fits on one line. */ +.ge-controls--in-options #ge-brush-section { gap: 6px; } +/* The brush controls (Color/Size/preset/caret) hug their content; only the + trailing slider section grows, so the Opacity/Flow/Softness group sits + right after the caret instead of leaving a wide gap. */ +.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; } +.ge-controls--in-options .ge-control-row { + margin: 0 !important; + flex: 0 0 auto; + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; +} +.ge-controls--in-options .ge-control-row label { white-space: nowrap; } +/* The selection action row (visibility, clear ×, Invert, Erase, Copy Layer, + To Mask, Quick Mask) rides a touch high in the thin strip; nudge it up 2px + to sit on the same optical line as the Style/Free row before it. */ +.ge-controls--in-options .ge-control-row.ge-actions { position: relative; top: -2px; margin-left: auto; } +/* The action buttons themselves ride ~2px below the mode/Style rows in the + thin strip; lift them so the whole row reads on one line (wand + marquee). */ +.ge-controls--in-options .ge-control-row.ge-actions .ge-btn { + position: relative; + top: -2px; +} +/* Pen selection: the action row belongs on the left edge (not pushed right by + the shared auto margin), and its ✓ should sit on the same optical line as + the ✕ in Cancel. */ +#ge-pen-section .ge-control-row.ge-actions { margin-left: 0; } +#ge-pen-commit svg { position: relative; top: 2px; } +/* Nudge the Clear ✕ glyph down 1px, and light it up (accent) while a + selection is active so it is obvious there is something to clear. */ +#ge-marquee-clear svg, +#ge-wand-clear svg, +#ge-lasso-clear svg, +#ge-sam-clear svg, +#ge-quickselect-clear svg, +#ge-objectselect-clear svg { position: relative; top: 1px; } +#ge-marquee-clear.has-selection, +#ge-wand-clear.has-selection, +#ge-lasso-clear.has-selection, +#ge-sam-clear.has-selection, +#ge-quickselect-clear.has-selection, +#ge-objectselect-clear.has-selection { color: var(--accent, var(--red)); border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, var(--border)); } +/* The Clone "clear sampled source" ✕ matches the wand/marquee ✕: the SVG cross + sits 1px lower, and the button lights up (accent) while a source is set. */ +#ge-clone-source-clear svg { position: relative; top: -1px; } +#ge-clone-source-clear.has-selection svg { top: 1px; } +#ge-clone-source-clear.has-selection { color: var(--accent, var(--red)); border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, var(--border)); } +/* Every clear ✕ is the same square target, wherever it lives: the wand/ + marquee/lasso/sam/quickselect/objectselect ones sit in the options strip + while the Clone one sits in the right panel, but they must read (and hit) + identically instead of inheriting each container's line-height. */ +#ge-marquee-clear, +#ge-wand-clear, +#ge-lasso-clear, +#ge-sam-clear, +#ge-quickselect-clear, +#ge-objectselect-clear, +#ge-clone-source-clear { + width: 26px; + height: 26px; + min-width: 26px; + min-height: 26px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + line-height: 1; +} +/* The marquee Style row lives in the nowrap options strip. When "Fixed + ratio/size" reveals the W/H inputs, their default intrinsic width + inflates the grid's max-content and the + @@ -2671,7 +2672,7 @@ - + diff --git a/static/js/a11y.js b/static/js/a11y.js index 814472d94..f44ada39d 100644 --- a/static/js/a11y.js +++ b/static/js/a11y.js @@ -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 + // 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 ${warningHtml}${showSearch ? `` : ''}
` + sortedModels.map(m => { - const mode = ['none', 'compact', 'full'].includes(String(m.tool_mode || '').toLowerCase()) + const mode = ['none', 'compact', 'full', 'spec'].includes(String(m.tool_mode || '').toLowerCase()) ? String(m.tool_mode).toLowerCase() : ''; return `
@@ -1370,7 +1371,8 @@ async function loadEndpoints() {
@@ -1440,7 +1442,7 @@ async function _saveEpModelState(epId, panel) { const value = String(sel.value || '').toLowerCase(); const original = String(sel.dataset.originalToolMode || '').toLowerCase(); if (modelId && (sel.dataset.toolModeTouched === '1' || original)) { - modelToolModes[modelId] = ['none', 'compact', 'full'].includes(value) ? value : ''; + modelToolModes[modelId] = ['none', 'compact', 'full', 'spec'].includes(value) ? value : ''; } }); const total = panel.querySelectorAll('input.adm-cb-hidden[type=checkbox]').length; diff --git a/static/js/chat.js b/static/js/chat.js index b02eb743c..da27508ef 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -44,6 +44,283 @@ 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 = ''; + // Inline chat images share the editor's 8192px dimension ceiling, with a + // conservative 16MP budget for its multiple canvas surfaces. No blob URLs are + // needed here: chat previews use server-authorized upload references. + const _MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; + const _MAX_INLINE_BYTES = 7 * 1024 * 1024; + const _MAX_IMAGE_DIMENSION = 8192; + const _MAX_IMAGE_PIXELS = 16 * 1024 * 1024; + let _inlineRasterCache = null; // One bounded source; repeated actions reuse its decode. + + function _imageDimensionsAllowed(width, height) { + return width > 0 && height > 0 && width <= _MAX_IMAGE_DIMENSION && + height <= _MAX_IMAGE_DIMENSION && width * height <= _MAX_IMAGE_PIXELS; + } + + function _rasterDimensions(bytes, mime) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const ascii = (offset, text) => [...text].every((char, i) => bytes[offset + i] === char.charCodeAt(0)); + if (mime === 'image/png' && bytes.length >= 33 && + bytes[0] === 137 && ascii(1, 'PNG\r\n\x1a\n') && view.getUint32(8) === 13 && ascii(12, 'IHDR')) { + return [view.getUint32(16), view.getUint32(20)]; + } + if (mime === 'image/gif' && bytes.length >= 13 && (ascii(0, 'GIF87a') || ascii(0, 'GIF89a'))) { + return [view.getUint16(6, true), view.getUint16(8, true)]; + } + if (mime === 'image/jpeg' && bytes[0] === 255 && bytes[1] === 216) { + let pos = 2; + while (pos + 4 <= bytes.length) { + if (bytes[pos++] !== 255) return null; + while (bytes[pos] === 255) pos++; + const marker = bytes[pos++]; + if (marker === 218 || marker === 217) break; + if (marker === 1 || (marker >= 208 && marker <= 215)) continue; + if (pos + 2 > bytes.length) return null; + const size = view.getUint16(pos); + if (size < 2 || pos + size > bytes.length) return null; + if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker) && size >= 8) { + return [view.getUint16(pos + 5), view.getUint16(pos + 3)]; + } + pos += size; + } + } + if (mime === 'image/webp' && bytes.length >= 30 && ascii(0, 'RIFF') && + view.getUint32(4, true) + 8 === bytes.length && ascii(8, 'WEBP')) { + if (ascii(12, 'VP8X')) return [1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16), 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16)]; + if (ascii(12, 'VP8L') && bytes[20] === 47) return [1 + ((bytes[21] | (bytes[22] << 8)) & 16383), 1 + (((bytes[22] >> 6) | (bytes[23] << 2) | (bytes[24] << 10)) & 16383)]; + if (ascii(12, 'VP8 ') && bytes[23] === 157 && bytes[24] === 1 && bytes[25] === 42) return [view.getUint16(26, true) & 16383, view.getUint16(28, true) & 16383]; + } + return null; + } + + function _inlineRaster(url) { + if (_inlineRasterCache?.url === url) return _inlineRasterCache; + const result = { url, valid: false }; + _inlineRasterCache = result; + if (url.length > _MAX_DATA_URL_LENGTH) return result; + const match = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,/i.exec(url); + if (!match) return result; + const payload = url.slice(match[0].length); + if (!payload || payload.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) return result; + const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0; + const size = payload.length / 4 * 3 - padding; + if (size > _MAX_INLINE_BYTES) return result; + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + if (padding && (alphabet.indexOf(payload[payload.length - padding - 1]) & (padding === 2 ? 15 : 3))) return result; + try { + const bytes = typeof Uint8Array.fromBase64 === 'function' ? Uint8Array.fromBase64(payload) : (() => { + const binary = atob(payload); const array = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i); + return array; + })(); + const mime = match[1].toLowerCase().replace('image/jpg', 'image/jpeg'); + const dimensions = _rasterDimensions(bytes, mime); + if (!dimensions || !_imageDimensionsAllowed(...dimensions)) return result; + Object.assign(result, { valid: true, bytes, mime, dimensions }); + } catch (_) {} + return result; + } + + // This synchronous guard rejects untrusted references before any fetch/decode. + // Inline images additionally require a successful browser decode before launch. + function _isAuthorizedImageUrl(url) { + if (!url || typeof url !== 'string') return false; + const trimmed = url.trim(); + if (trimmed.startsWith('data:')) return _inlineRaster(trimmed).valid; + if (!trimmed || trimmed.startsWith('blob:') || trimmed.startsWith('//')) return false; + try { + const base = window.location.origin; + const parsed = new URL(trimmed, base); + return parsed.origin === base && !parsed.search && !parsed.hash && + /^\/api\/(?:upload|gallery)\/[A-Za-z0-9_\-]+(?:\.[a-zA-Z0-9]+)?$/.test(parsed.pathname); + } catch (_) { return false; } + } + + async function _validateInlineImage(url) { + const raster = _inlineRaster(url.trim()); + if (!raster.valid) return false; + if (!raster.decoded) raster.decoded = (async () => { + try { + const image = await createImageBitmap(new Blob([raster.bytes], { type: raster.mime })); + try { + return _imageDimensionsAllowed(image.width, image.height) && + image.width * image.height === raster.dimensions[0] * raster.dimensions[1]; + } finally { image.close(); } + } catch (_) { return false; } + finally { raster.bytes = null; } + })(); + return raster.decoded; + } + + // Find the specific user message that originated the turn containing container. + function _findOriginatingUserMessage(container) { + if (!container) return null; + let curr = container.previousElementSibling; + while (curr) { + if (curr.classList && curr.classList.contains('msg-user')) { + return curr; + } + curr = curr.previousElementSibling; + } + const box = document.getElementById('chat-history'); + if (box && box.contains(container)) { + const all = Array.from(box.querySelectorAll('.msg, .agent-thread, .ai-msg-wrap, .round-holder')); + const idx = all.findIndex((el) => el === container || el.contains(container)); + if (idx > 0) { + for (let i = idx - 1; i >= 0; i--) { + if (all[i].classList && all[i].classList.contains('msg-user')) { + return all[i]; + } + } + } + } + return null; + } + + // Resolve the image attached to the exact turn associated with container / action. + // Never falls back to unrelated images from earlier conversation turns. + function _attachedImageForTurn(container, action) { + const explicitId = action && (action.imageId || action.image_id || action.fileId || action.file_id); + if (explicitId && typeof explicitId === 'string' && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(explicitId)) { + return (API_BASE || '') + '/api/upload/' + explicitId; + } + const explicitUrl = action && (action.imageUrl || action.image_url); + if (explicitUrl) return _isAuthorizedImageUrl(explicitUrl) ? explicitUrl : null; + + const userMsg = _findOriginatingUserMessage(container); + if (!userMsg) return null; + + const previews = userMsg.querySelectorAll( + '.attach-image-preview[data-file-id], .attach-card[data-file-id]' + ); + for (let i = previews.length - 1; i >= 0; i--) { + const fid = previews[i].dataset ? previews[i].dataset.fileId : null; + if (fid && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(fid)) { + return (API_BASE || '') + '/api/upload/' + fid; + } + const img = previews[i].querySelector('img'); + const src = img ? (img.getAttribute('src') || '') : ''; + if (_isAuthorizedImageUrl(src)) return src; + } + + const imgs = userMsg.querySelectorAll('.attach-image-preview img, .attach-card img'); + for (let i = imgs.length - 1; i >= 0; i--) { + const src = imgs[i].getAttribute('src') || ''; + if (_isAuthorizedImageUrl(src)) return src; + } + + return null; + } + + function _currentAttachedImageUrl(container, action) { + return _attachedImageForTurn(container, action); + } + + function _notifyError(msg) { + try { + if (uiModule && typeof uiModule.showError === 'function') { + uiModule.showError(msg); + return; + } + } catch (_) {} + console.warn('[chat]', msg); + } + + // Render the one-click "Open in editor & apply" button on a photo-edit reply + // 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 () => { + if (btn.disabled) return; + btn.disabled = true; + try { + let imageUrl = _attachedImageForTurn(container, action); + if (!imageUrl) { + _notifyError('No attached photo found for this conversation turn.'); + btn.disabled = false; + return; + } + + if (!_isAuthorizedImageUrl(imageUrl)) { + _notifyError('Unauthorized or invalid image reference.'); + btn.disabled = false; + return; + } + + if (imageUrl.trim().startsWith('data:') && !await _validateInlineImage(imageUrl)) { + _notifyError('Invalid or oversized image attachment.'); + return; + } + // Gallery references name an owner-scoped metadata resource, not image bytes. + if (!imageUrl.trim().startsWith('data:') && new URL(imageUrl, window.location.origin).pathname.startsWith('/api/gallery/')) { + const response = await fetch(imageUrl, { credentials: 'same-origin' }); + if (!response.ok) { _notifyError('The gallery image is unavailable.'); return; } + const image = await response.json(); + const source = new URL(image.url || '', window.location.origin); + if (source.origin !== window.location.origin || source.search || source.hash || + !/^\/api\/generated-image\/[A-Za-z0-9_.-]+$/.test(source.pathname)) { + _notifyError('Invalid gallery image reference.'); return; + } + imageUrl = source.href; + } + + // Verify attachment is accessible and authorized on the server before launching editor + if (imageUrl.includes('/api/upload/')) { + try { + const probe = await fetch(imageUrl, { method: 'HEAD' }); + if (!probe.ok) { + if (probe.status === 404 || probe.status === 410) { + _notifyError('The attached image is no longer available or upload has expired.'); + } else if (probe.status === 401 || probe.status === 403) { + _notifyError('You are not authorized to edit this attached image.'); + } else { + _notifyError('Could not verify image attachment authorization.'); + } + btn.disabled = false; + return; + } + } catch (_) { + _notifyError('Could not verify image attachment authorization.'); + btn.disabled = false; + return; + } + } + + if (typeof window.gePhotoEditorApply !== 'function') { + await import('/static/js/galleryEditor.js?v=20260909movepicklayer1'); + } + await window.gePhotoEditorApply({ + imageId: (action && (action.imageId || action.image_id)) || null, + imageUrl, + prompt: action.prompt || '', + displayName: 'Chat edit', + }); + } catch (err) { + console.error('[chat] open_and_apply failed', err); + _notifyError('Failed to apply edit: ' + (err.message || err)); + } finally { + btn.disabled = false; + } + }); + container.appendChild(btn); + } + + if (typeof window !== 'undefined') { + window.__chatPhotoEditorTestHooks = { + _isAuthorizedImageUrl, + _findOriginatingUserMessage, + _attachedImageForTurn, + renderPhotoEditorApplyButton, + }; + } + let API_BASE = ''; let currentAbort = null; let isStreaming = false; @@ -4095,6 +4372,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; @@ -6519,6 +6797,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; diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 99a1081ec..267b2e3e7 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -457,6 +457,7 @@ function _makeActionBtn(className, title, text, handler) { btn.className = className; btn.type = 'button'; btn.title = title; + btn.setAttribute('aria-label', title); btn.textContent = text; btn.addEventListener('click', handler); return btn; @@ -1941,7 +1942,7 @@ function _activateEntityAnchor(e, forcedAnchor = null) { else { const b = document.getElementById('tasks-btn'); if (b) b.click(); } }).catch(() => { const b = document.getElementById('tasks-btn'); if (b) b.click(); }); } else if (kind === 'skill') { - import('./skills.js?v=20261028skillsourceready1').then(mod => { + import('./skills.js?v=20260909kebabconsistency1').then(mod => { const open = mod.openSkill || (mod.default && mod.default.openSkill); if (open) open(id); }).catch(() => {}); @@ -2375,6 +2376,7 @@ export function createMsgFooter(msgElement, { animate = false } = {}) { moreBtn.className = 'msg-action-btn msg-more-btn'; moreBtn.type = 'button'; moreBtn.title = 'More actions'; + moreBtn.setAttribute('aria-label', 'More actions'); moreBtn.textContent = '\u00B7\u00B7\u00B7'; moreBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -2571,6 +2573,7 @@ export function createUserMsgFooter(msgElement) { moreBtn.className = 'msg-action-btn msg-more-btn'; moreBtn.type = 'button'; moreBtn.title = 'More actions'; + moreBtn.setAttribute('aria-label', 'More actions'); moreBtn.textContent = '\u00B7\u00B7\u00B7'; moreBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -3443,7 +3446,8 @@ export function addMessage(role, content, modelName, metadata) { for (const ev of roundTools) { const image = generatedImageResult(ev); if (image) { - box.appendChild(buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id)); + const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id); + box.appendChild(imgBubble); } } } diff --git a/static/js/chatStream.js b/static/js/chatStream.js index 4981ac34d..84497c163 100644 --- a/static/js/chatStream.js +++ b/static/js/chatStream.js @@ -212,6 +212,8 @@ export function handleUIControl(uiData) { var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes)); if (fn) fn(); }).catch(function(){}); + } else if (panel === 'tasks') { + document.getElementById('tool-tasks-btn')?.click(); } else if (panel === 'theme' || panel === 'themes') { import('./theme.js?v=20260911organsrain1').then(function(mod) { var fn = mod.togglePopup || (mod.default && mod.default.togglePopup); diff --git a/static/js/codeRunner.js b/static/js/codeRunner.js index fd896883d..c9ea1ac9d 100644 --- a/static/js/codeRunner.js +++ b/static/js/codeRunner.js @@ -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()) { diff --git a/static/js/colorPicker.js b/static/js/colorPicker.js index e28424a3b..a2901fb74 100644 --- a/static/js/colorPicker.js +++ b/static/js/colorPicker.js @@ -72,7 +72,12 @@ function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g // ── Storage ─────────────────────────────────────────────────────────── function getRecents() { - try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); } + try { + const values = JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); + return Array.isArray(values) + ? values.filter(value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)).slice(0, MAX_RECENT) + : []; + } catch { return []; } } @@ -85,17 +90,28 @@ function addRecent(hex) { } // ── Suggestions based on current color (5 harmony swatches) ────────── -function computeSuggestions() { +function suggestionsForHsv(h, s, v) { // Complement, analogous ±30°, split-complement (+150), tone shift return [ - { hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' }, - { hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' }, - { hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' }, - { hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' }, - { hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' }, + { hex: hsvToHex(h + 180, s, v), label: 'Complement' }, + { hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' }, + { hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' }, + { hex: hsvToHex(h + 150, s, v), label: 'Split-complement' }, + { hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' }, ]; } +// ── Reusable helpers for hosts that surface swatches inline ────────── +// (e.g. the editor's SWATCHES panel showing suggestions + recents). +function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); } + +export function getRecentColors() { return getRecents(); } +export function recordRecentColor(hex) { addRecent(hex); } +export function suggestColorsFor(hex) { + const { h, s, v } = hexToHsv(hex || '#000000'); + return suggestionsForHsv(h, s, v); +} + // ── Popover build ───────────────────────────────────────────────────── function buildPopover() { const p = document.createElement('div'); diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index f9511ed60..2ebf18086 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -1498,7 +1498,8 @@ function _rerenderCachedModels() { const _ggufOptions = _ggufChoices.map(f => `` ).join(''); - const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo); + const _minimaxM3Snapshot = '~/.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 _defaultServedModelName = _isMiniMaxM3 ? repo : ''; diff --git a/static/js/document.js b/static/js/document.js index a3e01dc45..e265a4420 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -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 @@ -15239,7 +15274,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 editorPane = document.querySelector('.doc-editor-pane'); + menu.style.zIndex = String(Math.max(topPortalZ(), (parseInt(getComputedStyle(editorPane).zIndex, 10) || 0) + 1)); const langLabel = lang ? lang.toUpperCase() : 'TXT'; // Form-backed markdown doc → primary export is the filled PDF, not the @@ -15314,7 +15350,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; } // Outside-click AND Escape both route through the central esc-stack via // bindMenuDismiss; onClose owns the actual node removal. - const close = bindMenuDismiss(menu, () => { menu.remove(); }); + const exportBtn = document.getElementById('doc-footer-export-btn'); + const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target) && !exportBtn?.contains(ev.target)); } function _richTextExportCss() { diff --git a/static/js/dragSort.js b/static/js/dragSort.js index 2262f8e51..85e4afc46 100644 --- a/static/js/dragSort.js +++ b/static/js/dragSort.js @@ -31,10 +31,15 @@ export function enable(containerId, itemSelector, options = {}) { onReorder: options.onReorder || null, handleSelector: options.handleSelector || null, excludeSelector: options.excludeSelector || null, + ignoreSelector: options.ignoreSelector || null, + // When > 0 the pointer must travel this many pixels before the drag + // starts, so rows that also handle plain clicks stay clickable. + threshold: Math.max(0, Number(options.threshold) || 0), storageKey: options.storageKey || null, }; let draggedEl = null; + let pending = null; let placeholder = null; let offsetY = 0; let items = []; @@ -130,6 +135,12 @@ export function enable(containerId, itemSelector, options = {}) { draggedEl.style.top = snapTop + 'px'; setTimeout(() => { + // The list may have been re-rendered mid-drag (e.g. dropping a layer + // effect copies it and rebuilds the panel), detaching both nodes. + if (!placeholder.parentNode || !draggedEl.isConnected) { + placeholder.remove(); + return; + } placeholder.parentNode.insertBefore(draggedEl, placeholder); placeholder.remove(); draggedEl.classList.remove('dragging'); @@ -158,10 +169,21 @@ export function enable(containerId, itemSelector, options = {}) { function onMouseDown(e) { if (e.button !== 0) return; if (config.handleSelector && !e.target.closest(config.handleSelector)) return; + if (config.ignoreSelector && e.target.closest(config.ignoreSelector)) return; const item = e.target.closest(itemSelector); if (!item || !container.contains(item)) return; if (config.excludeSelector && item.matches(config.excludeSelector)) return; + if (config.threshold > 0) { + // Defer the lift until the pointer actually moves, so a plain click + // (select layer, open the rename field) is left untouched. + pending = { item, startY: e.clientY }; + e.preventDefault(); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + return; + } + e.preventDefault(); e.stopPropagation(); startDrag(e.clientY, item); @@ -169,11 +191,21 @@ export function enable(containerId, itemSelector, options = {}) { document.addEventListener('mouseup', onMouseUp); } - function onMouseMove(e) { moveDrag(e.clientY); } + function onMouseMove(e) { + if (pending) { + if (Math.abs(e.clientY - pending.startY) < config.threshold) return; + const { item, startY } = pending; + pending = null; + e.preventDefault(); + startDrag(startY, item); + } + moveDrag(e.clientY); + } function onMouseUp() { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); + pending = null; endDrag(); } diff --git a/static/js/editor/actions-panel.js b/static/js/editor/actions-panel.js new file mode 100644 index 000000000..5cfc61182 --- /dev/null +++ b/static/js/editor/actions-panel.js @@ -0,0 +1,243 @@ +/** + * Actions panel (PhotoCraft parity, Phase 11) — a lightweight macro + * recorder/player. Every step maps to a known editor command by CSS + * selector; recording captures clicks on those selectors, and play + * replays them in order. Named actions persist to localStorage. + * + * The module owns no editor state: it only dispatches clicks on the + * selectors it is given. + */ + +export const ACTION_COMMANDS = Object.freeze([ + ['flip-h', 'Flip Horizontal', '[data-image-action="flip-h"]'], + ['flip-v', 'Flip Vertical', '[data-image-action="flip-v"]'], + ['rotate-90', 'Rotate 90° CW', '[data-image-action="rotate-90"]'], + ['rotate-180', 'Rotate 180°', '[data-image-action="rotate-180"]'], + ['fill', 'Fill Foreground', '[data-image-action="fill"]'], + ['merge-down', 'Merge Down', '#ge-merge-down'], + ['flatten', 'Flatten', '#ge-flatten'], + ['select-all', 'Select All', '[data-selection-action="all"]'], + ['deselect', 'Deselect', '[data-selection-action="deselect"]'], + ['invert-selection', 'Invert Selection', '[data-selection-action="invert"]'], + ['blur-gaussian', 'Gaussian Blur', '[data-filter-action="blur-gaussian"]'], + ['blur-motion', 'Motion Blur', '[data-filter-action="blur-motion"]'], + ['noise-add', 'Add Noise', '[data-filter-action="noise-add"]'], + ['pixelate-mosaic', 'Pixelate', '[data-filter-action="pixelate-mosaic"]'], + ['sharpen', 'Sharpen', '[data-filter-action="sharpen"]'], + ['stylize-emboss', 'Emboss', '[data-filter-action="stylize-emboss"]'], + ['distort-twirl', 'Twirl', '[data-filter-action="distort-twirl"]'], + ['rembg', 'Remove Background', '[data-tool-option="rembg"]'], + ['inpaint', 'Inpaint', '[data-tool="inpaint"]'], +]); + +const STORAGE_KEY = 'ge-actions'; + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text != null) node.textContent = text; + return node; +} + +function commandById(id) { + return ACTION_COMMANDS.find(command => command[0] === id) || null; +} + +export function createActionsPanel({ showToast = () => {}, getLayers = null, selectLayer = null } = {}) { + let steps = []; + let recording = false; + let saved = loadSaved(); + + const body = el('div', 'ge-panel-body ge-actions-body'); + + const nameRow = el('div', 'ge-actions-name-row'); + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.placeholder = 'Action name'; + nameInput.className = 'ge-actions-name'; + const saveButton = el('button', 'ge-btn ge-btn-sm', 'Save'); + saveButton.type = 'button'; + nameRow.append(nameInput, saveButton); + + const addRow = el('div', 'ge-actions-add-row'); + const addSelect = document.createElement('select'); + addSelect.className = 'ge-actions-command'; + ACTION_COMMANDS.forEach(([id, label]) => addSelect.appendChild(new Option(label, id))); + const addButton = el('button', 'ge-btn ge-btn-sm', 'Add'); + addButton.type = 'button'; + addRow.append(addSelect, addButton); + + const controls = el('div', 'ge-actions-controls'); + const recordButton = el('button', 'ge-btn ge-btn-sm', 'Record'); + recordButton.type = 'button'; + const playButton = el('button', 'ge-btn ge-btn-sm', 'Play'); + playButton.type = 'button'; + const clearButton = el('button', 'ge-btn ge-btn-sm', 'Clear'); + clearButton.type = 'button'; + controls.append(recordButton, playButton, clearButton); + + const batchRow = el('div', 'ge-actions-batch-row'); + const batchSelect = document.createElement('select'); + batchSelect.className = 'ge-actions-batch-select'; + const batchButton = el('button', 'ge-btn ge-btn-sm', 'Batch'); + batchButton.type = 'button'; + batchButton.title = 'Apply the selected action to every layer'; + const batchDocsButton = el('button', 'ge-btn ge-btn-sm', 'Batch docs'); + batchDocsButton.type = 'button'; + batchDocsButton.title = 'Apply the selected action to every open document'; + batchRow.append(batchSelect, batchButton, batchDocsButton); + + const list = el('div', 'ge-actions-list'); + const savedList = el('div', 'ge-actions-saved'); + + body.append(nameRow, addRow, controls, batchRow, list, savedList); + + function renderList() { + list.innerHTML = ''; + if (!steps.length) { + list.appendChild(el('p', 'ge-panel-empty', 'No steps. Add commands or record.')); + return; + } + steps.forEach((step, index) => { + const command = commandById(step); + const row = el('div', 'ge-actions-step'); + row.append( + el('span', 'ge-actions-step-index', String(index + 1)), + el('span', 'ge-actions-step-label', command ? command[1] : step), + ); + const remove = el('button', 'ge-action-remove', '✕'); + remove.type = 'button'; + remove.addEventListener('click', () => { steps.splice(index, 1); renderList(); }); + row.appendChild(remove); + list.appendChild(row); + }); + } + + function renderSaved() { + savedList.innerHTML = ''; + syncBatchSelect(); + const names = Object.keys(saved); + if (!names.length) return; + savedList.appendChild(el('div', 'ge-panel-title', 'SAVED')); + names.forEach(name => { + const row = el('div', 'ge-actions-saved-row'); + const loadButton = el('button', 'ge-btn ge-btn-sm', name); + loadButton.type = 'button'; + loadButton.addEventListener('click', () => { steps = saved[name].slice(); renderList(); }); + const deleteButton = el('button', 'ge-action-remove', '✕'); + deleteButton.type = 'button'; + deleteButton.addEventListener('click', () => { delete saved[name]; persist(); renderSaved(); }); + row.append(loadButton, deleteButton); + savedList.appendChild(row); + }); + } + + function persist() { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); } catch { /* ignore */ } + } + + function syncBatchSelect() { + const names = Object.keys(saved); + const previous = batchSelect.value; + batchSelect.innerHTML = ''; + if (!names.length) { + batchSelect.appendChild(new Option('No saved actions', '')); + batchButton.disabled = true; + batchDocsButton.disabled = true; + return; + } + batchButton.disabled = false; + batchDocsButton.disabled = false; + names.forEach(name => batchSelect.appendChild(new Option(name, name))); + if (names.includes(previous)) batchSelect.value = previous; + } + + function loadSaved() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : null; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { return {}; } + } + + function runStep(step) { + const command = commandById(step); + if (!command) return; + document.querySelector(command[2])?.click(); + } + + function play() { + steps.forEach((step, index) => setTimeout(() => runStep(step), index * 60)); + showToast(`Playing ${steps.length} action${steps.length === 1 ? '' : 's'}`); + } + + async function runOnAllLayers() { + const name = batchSelect.value; + const action = name && saved[name] ? saved[name].slice() : null; + if (!action || !action.length) { showToast('Select a saved action to batch'); return; } + if (!getLayers || !selectLayer) { showToast('Batch is unavailable'); return; } + const layers = getLayers() || []; + if (layers.length < 1) { showToast('No layers to batch'); return; } + showToast(`Batching "${name}" over ${layers.length} layer${layers.length === 1 ? '' : 's'}`); + for (const layer of layers) { + selectLayer(layer.id); + for (const step of action) { + runStep(step); + await new Promise(resolve => setTimeout(resolve, 80)); + } + } + showToast('Batch complete'); + } + + function onDocumentClick(event) { + if (!recording) return; + const match = ACTION_COMMANDS.find(command => event.target.closest?.(command[2])); + if (match && !steps.includes(match[0])) { + steps.push(match[0]); + renderList(); + } + } + + function toggleRecord() { + recording = !recording; + recordButton.classList.toggle('active', recording); + recordButton.textContent = recording ? 'Stop' : 'Record'; + showToast(recording ? 'Recording actions…' : 'Recording stopped'); + } + + addButton.addEventListener('click', () => { steps.push(addSelect.value); renderList(); }); + recordButton.addEventListener('click', toggleRecord); + playButton.addEventListener('click', play); + batchButton.addEventListener('click', () => { runOnAllLayers(); }); + batchDocsButton.addEventListener('click', async () => { + const name = batchSelect.value; + if (!name) { showToast('Select a saved action to batch'); return; } + try { + const response = await window.geEditorControl?.invoke?.('actions.batchDocuments', { name }); + const results = (response && response.result) || []; + const ok = results.filter((entry) => entry.ok).length; + showToast(`Batch docs: ${ok}/${results.length} document${results.length === 1 ? '' : 's'}`); + } catch (error) { + showToast('Batch docs failed: ' + (error?.message || error)); + } + }); + clearButton.addEventListener('click', () => { steps = []; renderList(); }); + saveButton.addEventListener('click', () => { + const name = (nameInput.value || '').trim(); + if (!name) return; + saved[name] = steps.slice(); + persist(); + renderSaved(); + showToast(`Saved action "${name}"`); + }); + document.addEventListener('click', onDocumentClick, true); + + const element = el('div', 'ge-panel'); + element.id = 'ge-panel-actions'; + element.appendChild(el('div', 'ge-panel-title', 'ACTIONS')); + element.appendChild(body); + renderList(); + renderSaved(); + + return { element, play, run: runStep, runOnAllLayers, getSteps: () => steps.slice(), setSteps: (next) => { steps = next.slice(); renderList(); } }; +} diff --git a/static/js/editor/adjustment-layer.js b/static/js/editor/adjustment-layer.js index e13e4ca53..71a27c99a 100644 --- a/static/js/editor/adjustment-layer.js +++ b/static/js/editor/adjustment-layer.js @@ -2,10 +2,12 @@ import { applyAdjustment } from './fx/pixel-pass.js'; import { defaultAdjParams } from './layer-helpers.js'; import { renderWithLayerMasks } from './composite-helpers.js'; +import { blendDraw } from './blend-modes.js'; const TYPES = new Set([ 'brightness-contrast', 'exposure', + 'develop', 'white-balance', 'hue-saturation', 'vibrance', @@ -16,6 +18,12 @@ const TYPES = new Set([ 'color-balance', 'selective-color', 'gradient-map', + 'invert', + 'posterize', + 'threshold', + 'photo-filter', + 'channel-mixer', + 'color-lookup', ]); function clone(value) { @@ -52,6 +60,11 @@ export function normalizeAdjustmentData(value) { params.ranges[name] = { ...clone(defaults.ranges[name]), ...(params.ranges[name] || {}) }; } } + if (type === 'channel-mixer') { + for (const name of ['red', 'green', 'blue', 'constant']) { + params[name] = { ...clone(defaults[name]), ...(params[name] || {}) }; + } + } return { type, params }; } @@ -109,11 +122,8 @@ export function drawAdjustmentLayer(target, editorState, layer, clippingBase, re adjusted = clipped; } - target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1; - target.globalCompositeOperation = layer.blendMode || 'source-over'; - target.drawImage(adjusted, 0, 0); - target.globalAlpha = 1; - target.globalCompositeOperation = 'source-over'; + const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1; + blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0); return true; } @@ -154,11 +164,8 @@ export async function drawAdjustmentLayerAsync(target, editorState, layer, clipp clippedCtx.globalCompositeOperation = 'source-over'; adjusted = clipped; } - target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1; - target.globalCompositeOperation = layer.blendMode || 'source-over'; - target.drawImage(adjusted, 0, 0); - target.globalAlpha = 1; - target.globalCompositeOperation = 'source-over'; + const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1; + blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0); return true; } diff --git a/static/js/editor/ai-models.js b/static/js/editor/ai-models.js index 5b4252f6b..a4a51609f 100644 --- a/static/js/editor/ai-models.js +++ b/static/js/editor/ai-models.js @@ -27,6 +27,12 @@ import { state } from './state.js'; import { sortModelIds } from '../modelSort.js'; +// Last result of loadAIModels() — consumed by the editor setup checklist +// (static/js/editor/preflight.js) to decide whether an image provider is +// reachable and which image models are available. +let _aiModelsStatus = { ready: false, models: [], error: null, at: 0 }; +export function getAIModelsStatus() { return _aiModelsStatus; } + export function resolveInpaintModel(select) { const options = Array.from(select?.options || []).filter(option => !option.disabled && option.value && option.value !== '__serve_cookbook__'); @@ -34,6 +40,70 @@ export function resolveInpaintModel(select) { return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null; } +// Resolve the model the Quick Edit box will use. Prefers the explicit Quick +// Edit picker (#ge-ai-command-model); falls back to the inpaint picker / Auto. +export function resolveQuickEditModel() { + const sel = document.getElementById('ge-ai-command-model'); + if (sel && sel.value && sel.value !== '__serve_cookbook__') { + const opt = sel.options[sel.selectedIndex]; + const raw = sel.value || ''; + const idx = raw.indexOf('::'); + return { + endpoint: idx < 0 ? raw : raw.slice(0, idx), + model: idx < 0 ? '' : raw.slice(idx + 2), + label: ((opt && opt.textContent) || '').trim(), + }; + } + const inp = document.getElementById('ge-ai-inpaint'); + const opt = resolveInpaintModel(inp); + if (!opt) return { endpoint: '', model: '', label: '' }; + const raw = opt.value || ''; + const idx = raw.indexOf('::'); + return { + endpoint: idx < 0 ? raw : raw.slice(0, idx), + model: idx < 0 ? '' : raw.slice(idx + 2), + label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')), + }; +} + +// Resolve the Quick Edit AGENT (planner) model — the vision-language model +// that reads the canvas screenshot and plans the edit. Default is the local +// Qwen3.5-9B VL server on seneca; "Server default" keeps the HARNESS planner. +export function resolveQuickEditAgentModel() { + const sel = document.getElementById('ge-ai-command-agent-model'); + const raw = (sel && sel.value) || ''; + if (!raw || raw === 'default') { + return { agent: '', endpoint: '', label: 'Server default' }; + } + const idx = raw.indexOf('::'); + const endpoint = idx < 0 ? '' : raw.slice(0, idx); + const agent = idx < 0 ? raw : raw.slice(idx + 2); + const opt = sel.options[sel.selectedIndex]; + return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() }; +} + +// Quick Edit agent (planner) model options. Kept small + explicit; +// dynamically populated from /api/editor/agent-models. +export const QUICK_EDIT_AGENT_OPTIONS = [ + { value: 'default', label: 'Server default (hosted planner)' }, +]; + + +// can see which model Quick Edit / inpaint will call. +export function updateFooterEditModel() { + const el = document.getElementById('ge-status-model'); + if (!el) return; + const info = resolveQuickEditModel(); + if (!info.label && !info.model) { + el.textContent = 'Edit: none'; + el.title = 'No AI edit model available'; + return; + } + const label = info.label || info.model; + el.textContent = `Edit: ${label}`; + el.title = `AI edit model: ${label}`; +} + // Heuristic classifier on a model id + endpoint name. A model can be: // - gen: text-to-image generation // - inpaint: image+mask edit (inpaint / img2img) @@ -70,12 +140,41 @@ export function modelCaps(modelId, endpointName, endpointType) { return { gen: false, inpaint: false }; } -export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) { +// Global listeners resolve live controls instead of retaining editor instances. +function syncCommandModel(value) { + const select = document.getElementById('ge-ai-command-model'); + if (!select || !select._modelsReady) return; + if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) { + if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model'); + value = ''; + } + if (select.value !== value) { + select.value = value; + if (select.value !== value) select.value = ''; + updateFooterEditModel(); + } +} +window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || '')); +window.addEventListener('storage', e => { + if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || ''); +}); +window.addEventListener('ge:model-endpoints-updated', e => { + document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || ''); +}); + +export function wireAIModelSelectors({ container, apiBase = '', openCookbookForImg2img = () => {} } = {}) { + const root = container || document; + root._aiModelListeners?.abort(); + const listeners = new AbortController(); + root._aiModelListeners = listeners; + const listen = (target, type, handler, options = {}) => target?.addEventListener(type, handler, { + ...(typeof options === 'boolean' ? { capture: options } : options), signal: listeners.signal, + }); // Delegated handler for the "+ Serve a model in Cookbook…" sentinel // option — catches clicks regardless of whether loadAIModels has // rewired the individual select yet and survives any innerHTML // reset later. - container.addEventListener('change', (e) => { + listen(root, 'change', (e) => { const sel = e.target.closest('select'); if (!sel) return; if (sel.value !== '__serve_cookbook__') return; @@ -85,79 +184,133 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im openCookbookForImg2img(); }); // Track prior value so we can restore it after the sentinel fires. - container.addEventListener('focus', (e) => { + listen(root, 'focus', (e) => { const sel = e.target.closest('select'); if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value; }, true); const aiGenSelect = document.getElementById('ge-ai-model'); const aiInpaintSelect = document.getElementById('ge-ai-inpaint'); + const aiCommandSelect = document.getElementById('ge-ai-command-model'); + const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model'); + if (aiInpaintSelect) listen(aiInpaintSelect, 'change', () => updateFooterEditModel()); + // The Quick Edit agent (planner) picker: dynamically populates from + // /api/editor/agent-models; restore/save choice from localStorage. + const populateAgentSelect = async () => { + if (!aiCommandAgentSelect) return; + const prevVal = aiCommandAgentSelect.value; + let agentOptions = [...QUICK_EDIT_AGENT_OPTIONS]; + try { + const res = await fetch(`${apiBase}/api/editor/agent-models`); + if (res.ok) { + const data = await res.json(); + if (listeners.signal.aborted || !root.isConnected) return; + const serverModels = data.models || []; + for (const m of serverModels) { + const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : '')}::${m.id}`; + const label = m.label || m.id; + if (!agentOptions.some(o => o.value === optVal)) { + agentOptions.push({ value: optVal, label }); + } + } + } + } catch (_) {} + + aiCommandAgentSelect.innerHTML = ''; + for (const opt of agentOptions) { + const el = document.createElement('option'); + el.value = opt.value; + el.textContent = opt.label; + aiCommandAgentSelect.appendChild(el); + } + try { + const saved = localStorage.getItem('ge-ai-command-agent-model'); + if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) { + aiCommandAgentSelect.value = saved; + } else if (prevVal && [...aiCommandAgentSelect.options].some(o => o.value === prevVal)) { + aiCommandAgentSelect.value = prevVal; + } + } catch {} + if (aiCommandAgentSelect._agentWired !== listeners) { + aiCommandAgentSelect._agentWired = listeners; + listen(aiCommandAgentSelect, 'change', () => { + try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {} + }); + } + }; // The global Gen model dropdown was removed from the editor topbar; // only bail if there's nothing to populate at all (neither the Gen // select nor the inpaint select nor any per-tool select). - if (!aiGenSelect && !aiInpaintSelect && + if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect && !document.querySelector('select.ge-tool-model')) return; + void populateAgentSelect(); async function loadAIModels(opts = {}) { try { const selectBaseUrl = opts.selectBaseUrl || ''; const prevGenValue = aiGenSelect?.value || ''; const prevInpaintValue = aiInpaintSelect?.value || ''; - const res = await fetch(`${apiBase}/api/model-endpoints`); + const res = await fetch(`${apiBase}/api/editor/image-models`); + if (listeners.signal.aborted || !root.isConnected) return; if (!res.ok) throw new Error(`Could not load image models (${res.status})`); - const endpoints = await res.json(); + const data = await res.json(); + if (listeners.signal.aborted || !root.isConnected) return; + const modelsList = data.models || []; if (aiGenSelect) aiGenSelect.innerHTML = ''; if (aiInpaintSelect) aiInpaintSelect.innerHTML = ''; + if (aiCommandSelect) aiCommandSelect.innerHTML = ''; const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model')); for (const ts of perToolSelects) ts.innerHTML = ''; let firstGen = null; let selectedGen = null; let selectedInpaint = null; - for (const ep of endpoints) { - if (!ep.is_enabled) continue; - const hasListedModels = Array.isArray(ep.models) && ep.models.length; - const models = hasListedModels ? sortModelIds(ep.models) : ['']; - const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image'; - // Image/inpaint endpoints can be called by URL even when their - // /models cache is still empty, so don't strand a freshly served - // Cookbook model as "(offline)" in the editor picker. - const epUsable = !!ep.online || isImageEndpoint; - for (const modelId of models) { - const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type); - if (!caps.gen && !caps.inpaint) continue; - // Encode "::" so the value carries both pieces. - const value = `${ep.base_url}::${modelId}`; - const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url); - const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : ''; - const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`; - if (caps.gen && aiGenSelect) { + const loaded = []; + for (const m of modelsList) { + const modelId = m.id || ''; + const caps = modelCaps(modelId, m.provider, m.kind); + const canGen = Boolean(caps.gen || m.kind === 'generate'); + const canInpaint = Boolean(caps.inpaint || m.kind === 'edit'); + if (!canGen && !canInpaint) continue; + + // Use opaque endpoint_id if available; never fall back to raw base_url + const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : ''); + const value = endpointKey ? `${endpointKey}::${modelId}` : modelId; + const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider); + const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : ''; + const label = `${shortModel}${epHint}`; + const epUsable = true; + loaded.push({ value, label, gen: canGen, inpaint: canInpaint, usable: epUsable, endpoint: endpointKey, model: modelId }); + if (canGen && aiGenSelect) { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + opt.disabled = !epUsable; + aiGenSelect.appendChild(opt); + if (epUsable && !firstGen) firstGen = value; + if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedGen) selectedGen = value; + } + if (canInpaint && aiInpaintSelect) { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + opt.disabled = !epUsable; + aiInpaintSelect.appendChild(opt); + if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedInpaint) selectedInpaint = value; + } + if (canInpaint || canGen) { + for (const ts of perToolSelects) { const opt = document.createElement('option'); opt.value = value; opt.textContent = label; opt.disabled = !epUsable; - aiGenSelect.appendChild(opt); - if (epUsable && !firstGen) firstGen = value; - if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value; + ts.appendChild(opt); } - if (caps.inpaint && aiInpaintSelect) { + if (aiCommandSelect) { const opt = document.createElement('option'); opt.value = value; opt.textContent = label; opt.disabled = !epUsable; - aiInpaintSelect.appendChild(opt); - if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value; - } - // Per-tool selectors get every img2img-capable entry. Both - // caps.inpaint AND caps.gen models work for harmonize / - // style / upscale (anything that can do img2img). - if (caps.inpaint || caps.gen) { - for (const ts of perToolSelects) { - const opt = document.createElement('option'); - opt.value = value; - opt.textContent = label; - opt.disabled = !epUsable; - ts.appendChild(opt); - } + aiCommandSelect.appendChild(opt); } } } @@ -173,6 +326,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' }); aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)'; } + updateFooterEditModel(); // Append the "Serve a model in Cookbook…" sentinel at the // bottom of every model dropdown. const appendServeSentinel = (sel) => { @@ -188,21 +342,52 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im for (const ts of perToolSelects) appendServeSentinel(ts); if (aiGenSelect) appendServeSentinel(aiGenSelect); if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect); - // Wire the sentinel on the Gen + Inpaint selects too. - const wireServeSentinel = (sel) => { - if (!sel) return; - let prev = sel.value; - sel.addEventListener('change', () => { - if (sel.value === '__serve_cookbook__') { - sel.value = prev; - openCookbookForImg2img(); - return; + if (aiCommandSelect) appendServeSentinel(aiCommandSelect); + // Sentinel changes are handled by the component's delegated listener. + if (aiCommandSelect) { + aiCommandSelect._modelsReady = true; + try { + const rawSaved = localStorage.getItem('ge-ai-command-model') || ''; + let saved = rawSaved; + if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) { + const modelPart = saved.includes('::') ? saved.split('::').pop() : ''; + const matching = loaded.filter(l => l.model === modelPart); + if (matching.length === 1) { + saved = matching[0].value; + } else { + saved = ''; + } + try { + if (saved) localStorage.setItem('ge-ai-command-model', saved); + else localStorage.removeItem('ge-ai-command-model'); + } catch {} } - prev = sel.value; - }); - }; - wireServeSentinel(aiGenSelect); - wireServeSentinel(aiInpaintSelect); + if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) { + aiCommandSelect.value = saved; + } else { + aiCommandSelect.value = ''; + } + if (aiCommandSelect.value !== rawSaved) { + if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); + else localStorage.removeItem('ge-ai-command-model'); + window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } })); + } + } catch {} + + if (aiCommandSelect._commandWired !== listeners) { + aiCommandSelect._commandWired = listeners; + listen(aiCommandSelect, 'change', () => { + if (aiCommandSelect.value === '__serve_cookbook__') return; + const val = aiCommandSelect.value || ''; + try { + if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); + else localStorage.removeItem('ge-ai-command-model'); + } catch {} + updateFooterEditModel(); + window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } })); + }); + } + } // Restore each per-tool selection from localStorage. for (const ts of perToolSelects) { const key = 'ge-tool-model-' + ts.dataset.geToolModel; @@ -213,7 +398,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im } } catch {} let prevValue = ts.value; - ts.addEventListener('change', () => { + listen(ts, 'change', () => { if (ts.value === '__serve_cookbook__') { ts.value = prevValue; openCookbookForImg2img(); @@ -223,40 +408,30 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im try { localStorage.setItem(key, ts.value); } catch {} }); } + _aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() }; + try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {} } catch (e) { + if (listeners.signal.aborted || !root.isConnected) return; // Fetch failed — still give the user the affordance to set up // a model. Otherwise the dropdown shows only "Auto" with no // hint about what to do next. const fallback = ''; if (aiGenSelect) aiGenSelect.innerHTML = fallback; if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback; + if (aiCommandSelect) aiCommandSelect.innerHTML = fallback; document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; }); - const wireServe = (sel) => { - if (!sel) return; - let prev = sel.value; - sel.addEventListener('change', () => { - if (sel.value === '__serve_cookbook__') { - sel.value = prev; - openCookbookForImg2img(); - return; - } - prev = sel.value; - }); - }; - wireServe(aiGenSelect); - wireServe(aiInpaintSelect); - document.querySelectorAll('select.ge-tool-model').forEach(wireServe); + void populateAgentSelect(); + updateFooterEditModel(); + _aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() }; + try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {} } } loadAIModels(); - const onModelEndpointsUpdated = (e) => { - if (!container.isConnected) { - window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated); - return; - } - loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' }); + if (aiCommandSelect) aiCommandSelect._refreshAIModels = baseUrl => { + if (listeners.signal.aborted || !root.isConnected) return; + void loadAIModels({ selectBaseUrl: baseUrl }); + void populateAgentSelect(); }; - window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated); // Re-fetch the model list when the user opens the inpaint dropdown, // so a model served via Cookbook mid-edit shows up without having to // close and reopen the editor. Debounced to one refresh per 3s so @@ -275,5 +450,5 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im if ([...sel.options].some(o => o.value === keep)) sel.value = keep; }); }; - container.addEventListener('mousedown', refreshOnOpen, true); + listen(root, 'mousedown', refreshOnOpen, true); } diff --git a/static/js/editor/ai-settings.js b/static/js/editor/ai-settings.js new file mode 100644 index 000000000..ec8cd846c --- /dev/null +++ b/static/js/editor/ai-settings.js @@ -0,0 +1,85 @@ +/** + * AI settings dialog. + * + * Holds the image-edit model, the AI agent (vision) model, and the opt-in + * "Double-check AI edits" toggle. Opened from the Quick Edit gear button and + * from the editor setup checklist; keeps the Quick Edit box itself uncluttered. + */ +export function createAiSettings({ container }) { + const el = document.createElement('div'); + el.className = 'ge-ai-settings'; + el.hidden = true; + el.setAttribute('role', 'dialog'); + el.setAttribute('aria-modal', 'true'); + el.setAttribute('aria-label', 'AI settings'); + el.innerHTML = ` +
+
+

AI settings

+ +
+
+ + + + +

Recommended, not required — the vision agent plans edits and can double-check results.

+ +
+ + 1.4× +
+

How strongly named looks (Kodachrome, teal and orange, …) are graded.

+ +
+
+ +
+
`; + + function open(focusId) { + el.hidden = false; + if (focusId) { + const target = document.getElementById(focusId); + if (target) { + try { target.focus({ preventScroll: true }); } catch (_) { target.focus(); } + } + } + } + function close() { el.hidden = true; } + + el.addEventListener('click', (e) => { + if (e.target === el || e.target.closest('[data-ai-settings-close]')) { + e.preventDefault(); + close(); + } + }); + const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); }; + document.addEventListener('keydown', onKey); + + container.appendChild(el); + + const strengthInput = el.querySelector('#ge-look-strength'); + const strengthVal = el.querySelector('#ge-look-strength-val'); + if (strengthInput) { + try { + const saved = parseFloat(localStorage.getItem('ge-look-strength')); + if (Number.isFinite(saved) && saved > 0) strengthInput.value = String(saved); + } catch (_) {} + if (strengthVal) strengthVal.textContent = Number(strengthInput.value).toFixed(1) + '×'; + strengthInput.addEventListener('input', () => { + const v = Number(strengthInput.value); + if (strengthVal) strengthVal.textContent = v.toFixed(1) + '×'; + try { localStorage.setItem('ge-look-strength', String(v)); } catch (_) {} + }); + } + + function destroy() { + document.removeEventListener('keydown', onKey); + el.remove(); + } + + return { element: el, open, close, destroy }; +} diff --git a/static/js/editor/ai-tools-misc.js b/static/js/editor/ai-tools-misc.js index b9cb9fea1..094d2cdf8 100644 --- a/static/js/editor/ai-tools-misc.js +++ b/static/js/editor/ai-tools-misc.js @@ -215,21 +215,19 @@ export function wireAIToolsMisc({ function addEmptyLayer() { saveState('Add layer'); const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight); - state.layers.push(layer); + // Photoshop inserts the new layer directly above the selected one. + const activeIndex = state.layers.findIndex(item => item.id === state.activeLayerId); + const insertIndex = activeIndex >= 0 ? activeIndex + 1 : state.layers.length; + state.layers.splice(insertIndex, 0, layer); state.activeLayerId = layer.id; renderLayerPanel(); composite(); } - const addButton = document.getElementById('ge-add-layer'); - addButton?.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); + const buildFxMenu = (anchor) => { document.querySelector('.ge-add-layer-menu')?.remove(); const menu = document.createElement('div'); menu.className = 'ge-add-layer-menu ge-frosted'; menu.innerHTML = ` - - @@ -242,9 +240,16 @@ export function wireAIToolsMisc({ + + + + + + + `; document.body.appendChild(menu); - const rect = addButton.getBoundingClientRect(); + const rect = anchor.getBoundingClientRect(); const menuRect = menu.getBoundingClientRect(); menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`; menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`; @@ -253,7 +258,7 @@ export function wireAIToolsMisc({ document.removeEventListener('pointerdown', away, true); }; const away = pointerEvent => { - if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close(); + if (!menu.contains(pointerEvent.target) && pointerEvent.target !== anchor) close(); }; requestAnimationFrame(() => document.addEventListener('pointerdown', away, true)); menu.addEventListener('click', clickEvent => { @@ -261,10 +266,26 @@ export function wireAIToolsMisc({ if (!button) return; clickEvent.preventDefault(); clickEvent.stopPropagation(); - if (button.dataset.layerKind === 'raster') addEmptyLayer(); - else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton); + if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, anchor); close(); }); + }; + + // "+" adds a plain pixel layer directly. + const addButton = document.getElementById('ge-add-layer'); + addButton?.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + document.querySelector('.ge-add-layer-menu')?.remove(); + addEmptyLayer(); + }); + + // "fx" opens the adjustment-layer chooser. + const addFxButton = document.getElementById('ge-add-fx'); + addFxButton?.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + buildFxMenu(addFxButton); }); return { addEmptyLayer }; diff --git a/static/js/editor/aspect-fit.js b/static/js/editor/aspect-fit.js new file mode 100644 index 000000000..55933f855 --- /dev/null +++ b/static/js/editor/aspect-fit.js @@ -0,0 +1,35 @@ +// Pure aspect-fitting geometry for the editor. When a whole-image resize is +// asked for a target aspect that differs from the current image, content must +// be cropped to that aspect (centred on a focus point) and then scaled +// UNIFORMLY — never stretched. +// +// aspectFitCropRect returns the document-space rectangle to crop FIRST so a +// subsequent uniform scale-to-{targetW,targetH} preserves the subject aspect +// ratio. Returns the full image unchanged when the aspect already matches. + +export function aspectFitCropRect({ width, height, focusX, focusY, targetAspect, tolerance = 0.005 } = {}) { + const w = Number(width); + const h = Number(height); + const a = Number(targetAspect); + if (!(w > 0) || !(h > 0) || !(a > 0)) return null; + const curAspect = w / h; + if (Math.abs(curAspect - a) / a <= tolerance) return { x: 0, y: 0, width: Math.round(w), height: Math.round(h) }; + const fx = Number.isFinite(Number(focusX)) ? Number(focusX) : w / 2; + const fy = Number.isFinite(Number(focusY)) ? Number(focusY) : h / 2; + let cw; + let ch; + if (curAspect > a) { + ch = h; + cw = Math.max(1, Math.round(ch * a)); + } else { + cw = w; + ch = Math.max(1, Math.round(cw / a)); + } + let x = Math.round(fx - cw / 2); + let y = Math.round(fy - ch / 2); + x = Math.max(0, Math.min(w - cw, x)); + y = Math.max(0, Math.min(h - ch, y)); + return { x, y, width: cw, height: ch }; +} + +export default { aspectFitCropRect }; diff --git a/static/js/editor/batch-runner.js b/static/js/editor/batch-runner.js new file mode 100644 index 000000000..f8dd421a4 --- /dev/null +++ b/static/js/editor/batch-runner.js @@ -0,0 +1,35 @@ +/** + * Batch runner — a small, dependency-free orchestrator that applies one action + * to every item in a list (open documents), isolating each item so a failure on + * one never aborts the batch. Pure async control flow: the caller supplies + * `runOne`, so the same loop is unit-testable without an editor. + */ + +/** + * @param {object} options + * @param {Array<{id:string,name?:string}>} options.documents + * @param {(doc:object, index:number)=>Promise} options.runOne + * @param {number} [options.delayMs] + * @param {(done:number,total:number,doc:object)=>void} [options.onProgress] + * @returns {Promise>} + */ +export async function runBatchOverDocuments({ documents, runOne, delayMs = 0, onProgress = null } = {}) { + const list = Array.isArray(documents) ? documents.filter(Boolean) : []; + const results = []; + for (let index = 0; index < list.length; index += 1) { + const doc = list[index]; + try { + const output = await runOne(doc, index); + results.push({ id: doc.id, name: doc.name, ok: true, output: output === undefined ? null : output }); + } catch (error) { + results.push({ id: doc.id, name: doc.name, ok: false, error: String((error && error.message) || error) }); + } + if (typeof onProgress === 'function') { + try { onProgress(index + 1, list.length, doc); } catch { /* progress is best-effort */ } + } + if (delayMs > 0) await new Promise(resolve => setTimeout(resolve, delayMs)); + } + return results; +} + +export default { runBatchOverDocuments }; diff --git a/static/js/editor/blend-modes.js b/static/js/editor/blend-modes.js new file mode 100644 index 000000000..ee3f3f8e6 --- /dev/null +++ b/static/js/editor/blend-modes.js @@ -0,0 +1,256 @@ +/** + * Layer/graphic blend modes. + * + * Canvas2D can only composite a subset of the Photoshop blend modes natively. + * This module exposes that native set plus the "extra" modes PhotoCraft offers + * (Dissolve, Linear Burn, Darker Color, Linear Dodge/Add, Lighter Color, + * Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide) and a + * single `blendDraw` entry point that both paths go through. + * + * The non-native modes follow the W3C compositing spec (separable + the two + * non-separable luminance modes) and operate on ImageData in the overlap of + * the source and the destination so rendering stays reasonably cheap. + */ + +export const NATIVE_BLEND_MODES = new Set([ + 'source-over', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + 'hue', + 'saturation', + 'color', + 'luminosity', +]); + +const NATIVE_ALIASES = { + 'linear-dodge': 'lighter', + add: 'lighter', +}; + +export const EXTRA_BLEND_MODES = [ + { value: 'linear-dodge', label: 'Linear Dodge (Add)' }, + { value: 'linear-burn', label: 'Linear Burn' }, + { value: 'darker-color', label: 'Darker Color' }, + { value: 'lighter-color', label: 'Lighter Color' }, + { value: 'vivid-light', label: 'Vivid Light' }, + { value: 'linear-light', label: 'Linear Light' }, + { value: 'pin-light', label: 'Pin Light' }, + { value: 'hard-mix', label: 'Hard Mix' }, + { value: 'subtract', label: 'Subtract' }, + { value: 'divide', label: 'Divide' }, + { value: 'dissolve', label: 'Dissolve' }, +]; + +const NON_SEPARABLE = new Set(['darker-color', 'lighter-color']); + +export function isNativeBlendMode(mode) { + if (!mode) return true; + return NATIVE_BLEND_MODES.has(mode) || Object.prototype.hasOwnProperty.call(NATIVE_ALIASES, mode); +} + +export function isKnownBlendMode(mode) { + if (!mode) return true; + return isNativeBlendMode(mode) || EXTRA_BLEND_MODES.some(entry => entry.value === mode); +} + +function clamp01(value) { + return value < 0 ? 0 : value > 1 ? 1 : value; +} + +function lum(r, g, b) { + return 0.299 * r + 0.587 * g + 0.114 * b; +} + +// Separable per-channel blend functions on normalized [0,1] values. +function blendChannel(mode, cb, cs) { + switch (mode) { + case 'normal': + return cs; + case 'multiply': + return cb * cs; + case 'screen': + return cb + cs - cb * cs; + case 'overlay': + return cb <= 0.5 ? 2 * cb * cs : 1 - 2 * (1 - cb) * (1 - cs); + case 'darken': + return Math.min(cb, cs); + case 'lighten': + return Math.max(cb, cs); + case 'color-dodge': + if (cb === 0) return 0; + if (cs === 1) return 1; + return Math.min(1, cb / (1 - cs)); + case 'color-burn': + if (cb === 1) return 1; + if (cs === 0) return 0; + return 1 - Math.min(1, (1 - cb) / cs); + case 'hard-light': + return cs <= 0.5 ? 2 * cs * cb : 1 - 2 * (1 - cs) * (1 - cb); + case 'soft-light': { + if (cs <= 0.5) return cb - (1 - 2 * cs) * cb * (1 - cb); + const d = cb <= 0.25 ? ((16 * cb - 12) * cb + 4) * cb : Math.sqrt(cb); + return cb + (2 * cs - 1) * (d - cb); + } + case 'difference': + return Math.abs(cb - cs); + case 'exclusion': + return cb + cs - 2 * cb * cs; + case 'linear-burn': + return clamp01(cb + cs - 1); + case 'linear-dodge': + return clamp01(cb + cs); + case 'vivid-light': + return cs <= 0.5 + ? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs))) + : (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs)))); + case 'linear-light': + return clamp01(cb + 2 * cs - 1); + case 'pin-light': + return cs <= 0.5 ? Math.min(cb, 2 * cs) : Math.max(cb, 2 * cs - 1); + case 'hard-mix': { + const vivid = cs <= 0.5 + ? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs))) + : (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs)))); + return vivid < 0.5 ? 0 : 1; + } + case 'subtract': + return clamp01(cb - cs); + case 'divide': + return cs === 0 ? 1 : clamp01(cb / cs); + default: + return cs; + } +} + +function dissolveRandom(i) { + let seed = (i * 2654435761) >>> 0; + seed ^= seed >>> 13; + seed = (seed * 1274126177) >>> 0; + return seed / 4294967296; +} + +function ensureSourceData(source) { + const sw = source.width || source.naturalWidth || 0; + const sh = source.height || source.naturalHeight || 0; + if (!sw || !sh) return null; + const scratch = document.createElement('canvas'); + scratch.width = sw; + scratch.height = sh; + const sctx = scratch.getContext('2d'); + sctx.drawImage(source, 0, 0, sw, sh); + return { data: sctx.getImageData(0, 0, sw, sh).data, sw, sh }; +} + +function blendCustom(ctx, source, mode, alpha, dx, dy) { + if (alpha <= 0) return; + const src = ensureSourceData(source); + if (!src) return; + const tw = ctx.canvas.width; + const th = ctx.canvas.height; + const sw = src.sw; + const sh = src.sh; + const sx0 = Math.max(0, -dx); + const sy0 = Math.max(0, -dy); + const dx0 = Math.max(0, dx); + const dy0 = Math.max(0, dy); + const w = Math.min(sw - sx0, tw - dx0); + const h = Math.min(sh - sy0, th - dy0); + if (w <= 0 || h <= 0) return; + + const dst = ctx.getImageData(dx0, dy0, w, h); + const dd = dst.data; + const sd = src.data; + const nonSeparable = NON_SEPARABLE.has(mode); + const dissolve = mode === 'dissolve'; + + for (let y = 0; y < h; y += 1) { + for (let x = 0; x < w; x += 1) { + const si = ((y + sy0) * sw + (x + sx0)) * 4; + const di = (y * w + x) * 4; + const sa = (sd[si + 3] / 255) * alpha; + const ba = dd[di + 3] / 255; + + if (dissolve) { + const seed = (y + dy0) * 73856093 ^ (x + dx0) * 19349663; + if (dissolveRandom(seed) >= sa) continue; + dd[di] = sd[si]; + dd[di + 1] = sd[si + 1]; + dd[di + 2] = sd[si + 2]; + dd[di + 3] = 255; + continue; + } + + if (sa === 0) continue; + + const sr = sd[si] / 255; + const sg = sd[si + 1] / 255; + const sb = sd[si + 2] / 255; + const br = dd[di] / 255; + const bg = dd[di + 1] / 255; + const bb = dd[di + 2] / 255; + + let mergedR; + let mergedG; + let mergedB; + if (nonSeparable) { + const backdropLum = lum(br, bg, bb); + const sourceLum = lum(sr, sg, sb); + const useSource = mode === 'darker-color' ? sourceLum < backdropLum : sourceLum > backdropLum; + mergedR = useSource ? sr : br; + mergedG = useSource ? sg : bg; + mergedB = useSource ? sb : bb; + } else { + mergedR = blendChannel(mode, br, sr); + mergedG = blendChannel(mode, bg, sg); + mergedB = blendChannel(mode, bb, sb); + } + + const srEff = (1 - ba) * sr + ba * mergedR; + const sgEff = (1 - ba) * sg + ba * mergedG; + const sbEff = (1 - ba) * sb + ba * mergedB; + + const ao = sa + ba * (1 - sa); + if (ao <= 0) continue; + const cr = (sa * srEff + ba * br * (1 - sa)) / ao; + const cg = (sa * sgEff + ba * bg * (1 - sa)) / ao; + const cbOut = (sa * sbEff + ba * bb * (1 - sa)) / ao; + + dd[di] = Math.round(clamp01(cr) * 255); + dd[di + 1] = Math.round(clamp01(cg) * 255); + dd[di + 2] = Math.round(clamp01(cbOut) * 255); + dd[di + 3] = Math.round(ao * 255); + } + } + ctx.putImageData(dst, dx0, dy0); +} + +/** + * Composite `source` onto a 2D context (or canvas) using `mode` at `alpha`. + * Native modes use globalCompositeOperation; the rest fall back to pixel math. + */ +export function blendDraw(target, source, mode, alpha = 1, dx = 0, dy = 0) { + if (!target || !source) return; + const ctx = typeof target.getContext === 'function' ? target.getContext('2d') : target; + const a = Number.isFinite(alpha) ? clamp01(alpha) : 1; + const normalized = mode || 'source-over'; + if (isNativeBlendMode(normalized)) { + const prevAlpha = ctx.globalAlpha; + const prevOp = ctx.globalCompositeOperation; + ctx.globalAlpha = a; + ctx.globalCompositeOperation = NATIVE_ALIASES[normalized] || normalized; + ctx.drawImage(source, dx, dy); + ctx.globalAlpha = prevAlpha; + ctx.globalCompositeOperation = prevOp; + return; + } + blendCustom(ctx, source, normalized, a, dx, dy); +} diff --git a/static/js/editor/brush-engine.js b/static/js/editor/brush-engine.js index 3c6950f98..97c9d093a 100644 --- a/static/js/editor/brush-engine.js +++ b/static/js/editor/brush-engine.js @@ -24,6 +24,32 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit emit(emitted); }; + // Emit evenly spaced dabs from the last emitted sample toward `target`, + // carrying the sub-spacing remainder forward. Shared by drag updates and + // the final pointer-up so a stroke ends on the same spacing grid instead + // of stamping one detached dab (which reads as a dot where drawing stopped). + const emitSpaced = (target) => { + if (!emitted) return; + const start = { ...emitted }; + const dx = target.x - start.x; + const dy = target.y - start.y; + const distance = Math.hypot(dx, dy); + if (!distance) return; + const diameter = Math.max(1, Number(getDiameter()) || 1); + const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01)); + let travelled = spacing - remainder; + while (travelled <= distance) { + const t = travelled / distance; + output({ + x: start.x + dx * t, + y: start.y + dy * t, + pressure: start.pressure + (target.pressure - start.pressure) * t, + }); + travelled += spacing; + } + remainder = Math.max(0, distance - (travelled - spacing)); + }; + const begin = (sample) => { emitted = null; filtered = { ...sample, pressure: normalisePressure(sample.pressure) }; @@ -41,30 +67,14 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit y: filtered.y + (raw.y - filtered.y) * response, pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response, }; - const start = { ...emitted }; - const dx = filtered.x - start.x; - const dy = filtered.y - start.y; - const distance = Math.hypot(dx, dy); - if (!distance) return; - const diameter = Math.max(1, Number(getDiameter()) || 1); - const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01)); - let travelled = spacing - remainder; - while (travelled <= distance) { - const t = travelled / distance; - output({ - x: start.x + dx * t, - y: start.y + dy * t, - pressure: start.pressure + (filtered.pressure - start.pressure) * t, - }); - travelled += spacing; - } - remainder = Math.max(0, distance - (travelled - spacing)); + emitSpaced(filtered); }; const end = (sample) => { + // Reach the true pointer-up point along the spacing grid rather than with + // a single far dab, so the stroke does not terminate in a detached dot. if (sample && emitted) { - const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) }; - if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample); + emitSpaced({ ...sample, pressure: normalisePressure(sample.pressure) }); } emitted = null; filtered = null; diff --git a/static/js/editor/brush-presets.js b/static/js/editor/brush-presets.js index 5c1eab246..dc1ad0f8d 100644 --- a/static/js/editor/brush-presets.js +++ b/static/js/editor/brush-presets.js @@ -4,6 +4,7 @@ const BUILT_INS = [ { id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' }, { id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' }, { id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' }, + { id: 'pixel-1px', name: 'Pixel', size: 1, opacity: 100, flow: 100, softness: 0, spacing: 1, smoothing: 0, blendMode: 'source-over', pixel: true }, ]; export function loadBrushPresets(storage = globalThis.localStorage) { @@ -31,6 +32,7 @@ export function captureBrushPreset(state, name) { spacing: state.brushSpacing, smoothing: state.brushSmoothing, blendMode: state.brushBlendMode, + pixel: !!state.brushPixel, }; } @@ -43,4 +45,5 @@ export function applyBrushPreset(state, preset) { state.brushSpacing = preset.spacing; state.brushSmoothing = preset.smoothing; state.brushBlendMode = preset.blendMode || 'source-over'; + state.brushPixel = !!preset.pixel; } diff --git a/static/js/editor/build/command-palette.js b/static/js/editor/build/command-palette.js new file mode 100644 index 000000000..2bef2c873 --- /dev/null +++ b/static/js/editor/build/command-palette.js @@ -0,0 +1,80 @@ +/** Command palette (Ctrl/Cmd+K): fuzzy list of tools, panels and actions. */ + +export function createCommandPalette({ commands = [], onRun = null } = {}) { + const overlay = document.createElement('div'); + overlay.className = 'ge-command-palette'; + overlay.hidden = true; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Command palette'); + overlay.innerHTML = ` +
+ +
+
`; + document.body.appendChild(overlay); + + const input = overlay.querySelector('.ge-command-input'); + const list = overlay.querySelector('.ge-command-list'); + let filtered = []; + let activeIndex = 0; + + function render() { + const query = input.value.trim().toLowerCase(); + filtered = commands.filter(cmd => !query || String(cmd.label).toLowerCase().includes(query)); + list.innerHTML = ''; + if (!filtered.length) { + const empty = document.createElement('p'); + empty.className = 'ge-command-empty'; + empty.textContent = 'No matching commands'; + list.appendChild(empty); + return; + } + if (activeIndex >= filtered.length) activeIndex = 0; + filtered.forEach((cmd, index) => { + const row = document.createElement('button'); + row.type = 'button'; + row.className = `ge-command-item${index === activeIndex ? ' active' : ''}`; + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', index === activeIndex ? 'true' : 'false'); + row.textContent = cmd.label; + row.addEventListener('click', () => run(cmd)); + list.appendChild(row); + }); + } + + function run(cmd) { + close(); + if (typeof cmd.run === 'function') cmd.run(); + else if (typeof onRun === 'function') onRun(cmd); + } + + function open() { + overlay.hidden = false; + input.value = ''; + activeIndex = 0; + render(); + input.focus(); + } + + function close() { + overlay.hidden = true; + } + + input.addEventListener('input', () => { activeIndex = 0; render(); }); + input.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { event.preventDefault(); close(); } + else if (event.key === 'ArrowDown') { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); } + else if (event.key === 'ArrowUp') { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); } + else if (event.key === 'Enter') { event.preventDefault(); if (filtered[activeIndex]) run(filtered[activeIndex]); } + }); + overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); }); + + return { + element: overlay, + open, + close, + toggle: () => (overlay.hidden ? open() : close()), + isOpen: () => !overlay.hidden, + }; +} diff --git a/static/js/editor/build/controls.js b/static/js/editor/build/controls.js index 74f9d1fcb..fafa4a0ac 100644 --- a/static/js/editor/build/controls.js +++ b/static/js/editor/build/controls.js @@ -6,10 +6,10 @@ * sections are all toggled `display:none` here; the tool-switch handler * in galleryEditor.js shows the section matching the active tool. * - * @param {{ color: string, brushSize: number, wandTolerance: number }} ctx + * @param {{ color: string, brushSize: number, wandTolerance: number, bucketTolerance: number, cropAspectPreset: string, eyedropperSampleSize: number }} ctx * @returns {string} */ -export function controlsHTML({ color, brushSize, wandTolerance }) { +export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance = 32, cropAspectPreset = 'free', eyedropperSampleSize = 1 }) { const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000); return `
+
Position
@@ -33,31 +55,105 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
-
- +
+ + + + +
-
Stroke
-
- - -
-
- - -
-
- - -
-
- Size - Opacity - Flow +
@@ -96,19 +192,47 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
+
+
+ + + +
+