mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-08 14:12:22 +01:00
Compare commits
176
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8b196ba4 | ||
|
|
cee26bc522 | ||
|
|
0d25d49d15 | ||
|
|
bafb7949f8 | ||
|
|
3dd081c8d4 | ||
|
|
e33607fd4f | ||
|
|
1888839e06 | ||
|
|
1cb597ce12 | ||
|
|
54b734032c | ||
|
|
aea1317ade | ||
|
|
bebf53be3c | ||
|
|
a8e0ff1c8e | ||
|
|
31685b050a | ||
|
|
03d25943ee | ||
|
|
71e3649985 | ||
|
|
45a57c0c38 | ||
|
|
71f6dc4e5d | ||
|
|
0c8a06a937 | ||
|
|
eea39ce103 | ||
|
|
9a5a15e17f | ||
|
|
f141ad2be5 | ||
|
|
a6e9294394 | ||
|
|
cfbe4aeca4 | ||
|
|
6f0679bb25 | ||
|
|
93c93bf076 | ||
|
|
f0f7464dfa | ||
|
|
1e57f774d3 | ||
|
|
2b310010cf | ||
|
|
b88154ecf9 | ||
|
|
e7796f71d3 | ||
|
|
412789051e | ||
|
|
b8adc4eb60 | ||
|
|
2246215b79 | ||
|
|
06fee0a3d8 | ||
|
|
f3813c261c | ||
|
|
e7a376b161 | ||
|
|
fec2bb11d6 | ||
|
|
c811f76d44 | ||
|
|
02d28c06d0 | ||
|
|
00ecf1c74e | ||
|
|
63feb227bb | ||
|
|
12fcddefab | ||
|
|
b46aa02ce1 | ||
|
|
352ee706f3 | ||
|
|
98b359cd4c | ||
|
|
2a2256af0e | ||
|
|
4f131d7c21 | ||
|
|
945e747eb4 | ||
|
|
61051d28f0 | ||
|
|
d87ae2a7f5 | ||
|
|
0e49c4ef40 | ||
|
|
1aff71836b | ||
|
|
b1c3125865 | ||
|
|
dbee1155e6 | ||
|
|
56c7cdf804 | ||
|
|
fe5d5caf34 | ||
|
|
bb654482f3 | ||
|
|
f45724faed | ||
|
|
6fc6095aec | ||
|
|
ef16398c5c | ||
|
|
6037707430 | ||
|
|
5c542d03ac | ||
|
|
e79c81fbb8 | ||
|
|
4fe8fb28e5 | ||
|
|
84e232e7ee | ||
|
|
2cace27723 | ||
|
|
508c7b3d9d | ||
|
|
5b8a72931d | ||
|
|
2e7edf908d | ||
|
|
b80f9afb3e | ||
|
|
533d48ad8d | ||
|
|
d5bc7fb70e | ||
|
|
3b8c60ac1e | ||
|
|
d237c3fbba | ||
|
|
f510b1973a | ||
|
|
fe81c491cd | ||
|
|
1339efa856 | ||
|
|
6f5c28b345 | ||
|
|
aa2db8d87c | ||
|
|
8b8fc62449 | ||
|
|
0f996f58da | ||
|
|
18329268b0 | ||
|
|
6cc98f34b8 | ||
|
|
c493a66101 | ||
|
|
d5949c0286 | ||
|
|
9ec897d947 | ||
|
|
9257f0e6d0 | ||
|
|
a334a21d57 | ||
|
|
87ef52f7cc | ||
|
|
25617a8c10 | ||
|
|
4992cc620f | ||
|
|
59cca6b915 | ||
|
|
1ddda10716 | ||
|
|
958848f9f8 | ||
|
|
571ac09cea | ||
|
|
f979e5602f | ||
|
|
0f95032177 | ||
|
|
b3e2e628ab | ||
|
|
f1a45e85c5 | ||
|
|
f843a3dc01 | ||
|
|
72ba37af6b | ||
|
|
5bf4524618 | ||
|
|
1a7c79946b | ||
|
|
6d102a213f | ||
|
|
de8f151632 | ||
|
|
cce5892eeb | ||
|
|
f4e3ae6e68 | ||
|
|
671cbd79ac | ||
|
|
2e1c2a343a | ||
|
|
0116f0a62b | ||
|
|
c237a1cb9c | ||
|
|
6b284d2bc5 | ||
|
|
8acaec20ee | ||
|
|
5f18bcf6f8 | ||
|
|
18b6140511 | ||
|
|
eeb1107f7b | ||
|
|
4795c8d462 | ||
|
|
5c738a5a8e | ||
|
|
0ff5d47a8c | ||
|
|
f780f1dce9 | ||
|
|
57a1859f08 | ||
|
|
1c5853003e | ||
|
|
0f8b137713 | ||
|
|
2c5d5005ba | ||
|
|
901b3ff42d | ||
|
|
c207ed1ed8 | ||
|
|
8b3a866899 | ||
|
|
a646b59110 | ||
|
|
4d8d363681 | ||
|
|
a7e8a6ef2e | ||
|
|
12631da4d3 | ||
|
|
927985f847 | ||
|
|
2046ce893f | ||
|
|
35ef91b25c | ||
|
|
91922d854c | ||
|
|
641752387b | ||
|
|
da8ba69c74 | ||
|
|
0b8176b11e | ||
|
|
375e263419 | ||
|
|
106b1e7c86 | ||
|
|
49d57a43fe | ||
|
|
2c48e7fdab | ||
|
|
9ab440c4cb | ||
|
|
fa29a86696 | ||
|
|
8cfdb8e2e5 | ||
|
|
76ab72412d | ||
|
|
56b85c359c | ||
|
|
04ca6c26c3 | ||
|
|
fdb2a330d9 | ||
|
|
08452ab5f9 | ||
|
|
e461a6b5c5 | ||
|
|
c54bc1d2d3 | ||
|
|
9ab3f70fe4 | ||
|
|
22144050fe | ||
|
|
3d47888559 | ||
|
|
ace6be44f3 | ||
|
|
36a71886bb | ||
|
|
b3f86a0724 | ||
|
|
94dc74e500 | ||
|
|
fd27e9a429 | ||
|
|
76abce349b | ||
|
|
4954cc2e8c | ||
|
|
bf3c000d1c | ||
|
|
7fd4e0e9bb | ||
|
|
8cd96aad46 | ||
|
|
5fac693c1c | ||
|
|
6123b70b5c | ||
|
|
bfa514f6e8 | ||
|
|
8fda203d28 | ||
|
|
1ff2fd0054 | ||
|
|
ad3e309074 | ||
|
|
15c4cef36a | ||
|
|
84cdd866c5 | ||
|
|
50b05bf54f | ||
|
|
dbfc96b8f3 | ||
|
|
a7c03f85bf |
@@ -179,6 +179,7 @@ jobs:
|
||||
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
python scripts/check_ca_trust.py --python
|
||||
uv pip install --system -r requirements.txt
|
||||
uv pip install --system "playwright==1.62.0"
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
@@ -256,3 +257,10 @@ jobs:
|
||||
env:
|
||||
PYTEST_SHARD: ${{ matrix.shard }}
|
||||
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
||||
|
||||
- name: Playwright mobile layout checks (shard 1/4 only)
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
|
||||
run: |
|
||||
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
|
||||
--config tests/e2e/ios-layout.config.cjs \
|
||||
--reporter=line
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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$''',
|
||||
]
|
||||
|
||||
+18
-3
@@ -1,9 +1,22 @@
|
||||
# Both stages need the fixed native decoder, including curl in the builder and
|
||||
# distro Node in the runtime. A cached Python base may predate the Debian fix.
|
||||
FROM python:3.14.9-slim AS brotli-base
|
||||
COPY scripts/check_brotli.py /usr/local/bin/check-brotli.py
|
||||
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
||||
# Check inherited trust before the first HTTPS package request, retaining Debian's
|
||||
# signed archive verification. Refresh only the explicit native dependency.
|
||||
RUN python /usr/local/bin/check-ca-trust.py --system \
|
||||
&& sed -i 's|http://deb.debian.org/|https://deb.debian.org/|g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libbrotli1 \
|
||||
&& python /usr/local/bin/check-brotli.py \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
||||
# 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 . .
|
||||
|
||||
@@ -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 <colour>"; 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`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -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);
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export function createEditor({
|
||||
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
|
||||
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
|
||||
</div>
|
||||
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button></div>` : ''}
|
||||
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button></div>` : ''}
|
||||
<div class="note-form-body">
|
||||
${type === 'note'
|
||||
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
|
||||
@@ -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 = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button>`;
|
||||
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button>`;
|
||||
// Insert AFTER the whole header (a flex-row), not after the
|
||||
// title input itself : otherwise the image lands as a sibling
|
||||
// of the title inside the header and flex puts them side-by-side.
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createText({
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
const href = url.startsWith('www.') ? `https://${url}` : url;
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
|
||||
"prompt": {"type": "string", "description": "Image description prompt"},
|
||||
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
|
||||
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
|
||||
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
|
||||
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default configured quality, otherwise medium)"},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
},
|
||||
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
if "quality" not in arguments and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
|
||||
# Auto-detect best available image model
|
||||
|
||||
+5
-1
@@ -9,7 +9,11 @@
|
||||
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:photo-editor:install": "playwright install chromium firefox webkit",
|
||||
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
|
||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:install": "playwright install chromium webkit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.7.0",
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
-r requirements.txt
|
||||
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
|
||||
pytest-xdist[psutil]>=3.8,<4
|
||||
playwright==1.62.0
|
||||
|
||||
@@ -887,6 +887,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"""Create a new integration (admin only)."""
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
item = add_integration(body)
|
||||
return {"ok": True, "integration": mask_integration_secret(item)}
|
||||
|
||||
@@ -895,6 +897,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"""Update an existing integration (admin only)."""
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
item = update_integration(integration_id, body)
|
||||
if not item:
|
||||
raise HTTPException(404, "Integration not found")
|
||||
|
||||
+16
-16
@@ -80,6 +80,18 @@ def _require_user(request: Request) -> str:
|
||||
return FALLBACK_OWNER
|
||||
|
||||
|
||||
async def _json_object_body(request: Request) -> dict:
|
||||
"""Parse a JSON object body. An unreadable body counts as empty; a body
|
||||
that is valid JSON but not an object (a list, string or number) is a 400."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
return body
|
||||
|
||||
|
||||
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
|
||||
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
|
||||
if not cal:
|
||||
@@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
async def save_config(request: Request):
|
||||
"""Legacy single-account endpoint — upserts the first account."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
accounts = _get_caldav_accounts(owner)
|
||||
if not (body.get("url") or "").strip():
|
||||
_save_caldav_accounts(owner, [])
|
||||
@@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
"""Add a new CalDAV account."""
|
||||
import uuid as _uuid
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
from src.caldav_sync import validate_caldav_url
|
||||
try:
|
||||
url = validate_caldav_url(body.get("url", ""))
|
||||
@@ -1080,10 +1086,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
async def update_caldav_account(account_id: str, request: Request):
|
||||
"""Update an existing CalDAV account by id."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
accounts = _get_caldav_accounts(owner)
|
||||
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
|
||||
if idx is None:
|
||||
@@ -1124,10 +1127,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
test an already-saved account. Falls back to the first saved account
|
||||
when nothing is provided."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
url = (body.get("url") or "").strip()
|
||||
user = (body.get("username") or "").strip()
|
||||
pw = body.get("password") or ""
|
||||
|
||||
+165
-1
@@ -14,6 +14,7 @@ from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
from routes.cookbook_output import HF_PARTIAL_BLOB_HELPER
|
||||
from core.platform_compat import _ssh_exec_argv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -378,6 +379,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
"""
|
||||
lines = [
|
||||
"import json, os, re, shutil, subprocess, urllib.request",
|
||||
*HF_PARTIAL_BLOB_HELPER.splitlines(),
|
||||
"models = []",
|
||||
"seen = set()",
|
||||
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
|
||||
@@ -436,8 +438,10 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
" sz, nf, ic = 0, 0, False",
|
||||
" if os.path.isdir(blobs):",
|
||||
" for f in os.scandir(blobs):",
|
||||
" if f.name.endswith('.incomplete'):",
|
||||
" if not is_partial_blob(f.path): continue",
|
||||
" ic = True",
|
||||
" if f.is_file(): nf += 1; sz += f.stat().st_size",
|
||||
" if f.name.endswith('.incomplete'): ic = True",
|
||||
" snap = os.path.join(cache, d, 'snapshots')",
|
||||
" def snapshot_size():",
|
||||
" total, count, incomplete = 0, 0, False",
|
||||
@@ -1030,6 +1034,166 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
|
||||
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
|
||||
|
||||
|
||||
ODYSSEUS_LLAMA_SHIM_MARKER = "# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike"
|
||||
|
||||
|
||||
def _append_llama_server_resolution_definition_lines(runner_lines: list[str]) -> None:
|
||||
"""Define the helper shell function that discovers, reconciles and prioritizes llama-server."""
|
||||
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||
runner_lines.append('_odysseus_resolve_llama_server() {')
|
||||
runner_lines.append(' _odysseus_llama_bin=""')
|
||||
runner_lines.append(f' local _marker={marker}')
|
||||
runner_lines.append(' local _saved_ifs="$IFS"')
|
||||
runner_lines.append(" IFS=':'")
|
||||
runner_lines.append(' local -a _parts')
|
||||
runner_lines.append(' read -r -a _parts <<< "$PATH"')
|
||||
runner_lines.append(' IFS="$_saved_ifs"')
|
||||
runner_lines.append(' local _dir _cand')
|
||||
runner_lines.append(' for _dir in "${_parts[@]}"; do')
|
||||
runner_lines.append(' [ -n "$_dir" ] || continue')
|
||||
runner_lines.append(' _cand="$_dir/llama-server"')
|
||||
runner_lines.append(' [ -x "$_cand" ] || continue')
|
||||
runner_lines.append(' if [ -f "$_cand" ] && [ ! -L "$_cand" ] && grep -Fxq "$_marker" "$_cand" 2>/dev/null; then')
|
||||
runner_lines.append(' continue')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if "$_cand" --version >/dev/null 2>&1; then')
|
||||
runner_lines.append(' _odysseus_llama_bin="$_cand"')
|
||||
runner_lines.append(' break')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' done')
|
||||
runner_lines.append(' if [ -f "$HOME/bin/llama-server" ] && [ ! -L "$HOME/bin/llama-server" ] && grep -Fxq "$_marker" "$HOME/bin/llama-server" 2>/dev/null; then')
|
||||
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' echo "[odysseus] Native llama-server detected — retiring Odysseus compatibility shim in ~/bin"')
|
||||
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' elif python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_llama_bin="$HOME/bin/llama-server"')
|
||||
runner_lines.append(' else')
|
||||
runner_lines.append(' echo "[odysseus] Removing stale Odysseus llama-server shim (python3 -m llama_cpp.server unavailable)"')
|
||||
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' local _cand_dir')
|
||||
runner_lines.append(' _cand_dir="$(dirname "$_odysseus_llama_bin")"')
|
||||
runner_lines.append(' if [ "${PATH%%:*}" != "$_cand_dir" ]; then')
|
||||
runner_lines.append(' export PATH="$_cand_dir:$PATH"')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' return 0')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append('}')
|
||||
|
||||
|
||||
def _append_llama_server_shim_definition_lines(runner_lines: list[str]) -> None:
|
||||
"""Define the helper shell function that generates the Odysseus compatibility shim."""
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||
runner_lines.append('_odysseus_create_llama_shim() {')
|
||||
runner_lines.append(' local _target="$HOME/bin/llama-server"')
|
||||
runner_lines.append(f' local _marker={marker}')
|
||||
runner_lines.append(' if [ -e "$_target" ] || [ -L "$_target" ]; then')
|
||||
runner_lines.append(' if [ -f "$_target" ] && [ ! -L "$_target" ] && grep -Fxq "$_marker" "$_target" 2>/dev/null; then')
|
||||
runner_lines.append(' : # existing Odysseus shim — safe to replace')
|
||||
runner_lines.append(' else')
|
||||
runner_lines.append(' echo "[odysseus] WARNING: $_target already exists and is not an Odysseus-managed shim; leaving untouched." >&2')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' mkdir -p "$HOME/bin" || return 1')
|
||||
runner_lines.append(' local _tmp')
|
||||
runner_lines.append(' _tmp="$(mktemp "$HOME/bin/.llama-server.odysseus.tmp.XXXXXX")" || {')
|
||||
runner_lines.append(' echo "[odysseus] ERROR: unable to create secure temporary llama-server shim in $HOME/bin" >&2')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' }')
|
||||
runner_lines.append(" if ! cat > \"$_tmp\" <<'_ODY_LLAMA_SHIM_EOF'")
|
||||
runner_lines.append('#!/usr/bin/env bash')
|
||||
runner_lines.append(ODYSSEUS_LLAMA_SHIM_MARKER)
|
||||
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
||||
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
||||
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
||||
runner_lines.append('ARGS=()')
|
||||
runner_lines.append('while [ $# -gt 0 ]; do')
|
||||
runner_lines.append(' case "$1" in')
|
||||
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
||||
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
||||
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
||||
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
||||
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
||||
runner_lines.append(' esac')
|
||||
runner_lines.append('done')
|
||||
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
||||
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
||||
runner_lines.append(' then')
|
||||
runner_lines.append(' rm -f -- "$_tmp"')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' chmod +x "$_tmp" || { rm -f -- "$_tmp"; return 1; }')
|
||||
runner_lines.append(' mv -f -- "$_tmp" "$_target" || { rm -f -- "$_tmp"; return 1; }')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
||||
runner_lines.append('}')
|
||||
|
||||
|
||||
def _append_llama_server_shim_reconciliation_lines(runner_lines: list[str]) -> None:
|
||||
"""Reconcile an existing ~/bin/llama-server if it is owned by Odysseus Cookbook.
|
||||
|
||||
Invariants:
|
||||
- Never touch an arbitrary user-owned executable or symlink.
|
||||
- If ~/bin/llama-server is an Odysseus generated shim:
|
||||
- If a native llama-server is present and healthy elsewhere on PATH, remove the shim
|
||||
so the native binary is used.
|
||||
- If python3 -m llama_cpp.server is unavailable, remove the stale shim
|
||||
and hash -r so the fallback/build path is triggered.
|
||||
- If no healthy native server exists and llama_cpp.server is working, preserve it.
|
||||
- If a healthy native candidate exists on PATH (even later in PATH), prioritize it and
|
||||
ensure the launched executable is the validated candidate.
|
||||
"""
|
||||
runner_lines.append('[ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env')
|
||||
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('_odysseus_resolve_llama_server || true')
|
||||
|
||||
|
||||
def _append_llama_server_finalization_lines(runner_lines: list[str], platform_hint: str = "") -> None:
|
||||
"""Reconcile post-install launcher availability and enforce fail-closed invariant.
|
||||
|
||||
Guarantees that if python3 -m llama_cpp.server is usable but the `llama-server`
|
||||
command is unresolvable, the compatibility launcher is created. If no usable
|
||||
launcher exists after all build/install attempts, sets ODYSSEUS_PREFLIGHT_EXIT=127.
|
||||
"""
|
||||
err_msg = (
|
||||
f"ERROR: llama.cpp serving is not available on {platform_hint} after install attempts."
|
||||
if platform_hint
|
||||
else "ERROR: llama.cpp serving is not available after install/build attempts."
|
||||
)
|
||||
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_create_llama_shim')
|
||||
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('if [ -z "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(f' echo "{err_msg}"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
|
||||
|
||||
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
|
||||
"""Shell command that clears the Cookbook-managed llama.cpp build.
|
||||
|
||||
|
||||
@@ -8,6 +8,20 @@ import re
|
||||
|
||||
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||
|
||||
# Shared source embedded in local/remote inventory and status probes. A blank
|
||||
# retry marker beside its completed, nonempty content-addressed blob is stale;
|
||||
# a blank marker alone may be a download that has not received its first byte.
|
||||
HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
|
||||
if not path.endswith('.incomplete'):
|
||||
return False
|
||||
try:
|
||||
completed = path[:-len('.incomplete')]
|
||||
return (os.path.getsize(path) != 0 or not os.path.isfile(completed)
|
||||
or os.path.getsize(completed) == 0)
|
||||
except OSError:
|
||||
return True
|
||||
"""
|
||||
|
||||
# Probe scripts for the dead-session download check, run as
|
||||
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
|
||||
# cache_root is the task's custom download dir, '' for the default HF cache.
|
||||
@@ -15,7 +29,7 @@ _FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
|
||||
# the probe process's own environment knows nothing about it.
|
||||
HF_CACHE_COMPLETE_PROBE = (
|
||||
"import os,sys;"
|
||||
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||
"repo=sys.argv[1];"
|
||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||
@@ -24,18 +38,18 @@ HF_CACHE_COMPLETE_PROBE = (
|
||||
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
||||
"inc=False;"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if ok and not inc else 1)"
|
||||
)
|
||||
|
||||
HF_CACHE_INCOMPLETE_PROBE = (
|
||||
"import os,sys;"
|
||||
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||
"repo=sys.argv[1];"
|
||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if inc else 1)"
|
||||
)
|
||||
|
||||
|
||||
+17
-57
@@ -57,9 +57,11 @@ from routes.cookbook_helpers import (
|
||||
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
_diagnose_serve_output, run_ssh_command_async,
|
||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
||||
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
|
||||
_normalize_llama_cpp_python_cache_types,
|
||||
_append_llama_server_shim_definition_lines,
|
||||
_append_llama_server_shim_reconciliation_lines,
|
||||
_append_llama_server_finalization_lines,
|
||||
ModelDownloadRequest, ServeRequest,
|
||||
)
|
||||
|
||||
@@ -2161,7 +2163,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
ps_lines.append('}')
|
||||
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
|
||||
ps_lines.append('# Auto-install llama-cpp-python if missing')
|
||||
ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}')
|
||||
ps_lines.append('try { python -c "import llama_cpp.server" 2>$null } catch {}')
|
||||
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
|
||||
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
|
||||
ps_lines.append(' python -m pip install llama-cpp-python[server]')
|
||||
@@ -2247,14 +2249,17 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# ollama is found (otherwise macOS falls back to a slow source build).
|
||||
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||
_append_llama_server_shim_definition_lines(runner_lines)
|
||||
_append_llama_server_shim_reconciliation_lines(runner_lines)
|
||||
runner_lines.append('if [ -d /data/data/com.termux ]; then')
|
||||
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
|
||||
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' if ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' pkg install -y cmake 2>/dev/null')
|
||||
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
|
||||
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append('elif ! command -v llama-server &>/dev/null; then')
|
||||
_append_llama_server_finalization_lines(runner_lines, platform_hint="Termux")
|
||||
runner_lines.append('elif [ -z "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
|
||||
runner_lines.append(' mkdir -p ~/bin')
|
||||
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
|
||||
@@ -2271,8 +2276,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
|
||||
# explicit so the binary is optimized (Metal auto-enables on macOS).
|
||||
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
|
||||
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\')
|
||||
runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
||||
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server')
|
||||
runner_lines.append(' else')
|
||||
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
||||
runner_lines.append(' fi')
|
||||
@@ -2296,58 +2300,13 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
# SHORT-CIRCUIT before the build/pip fallback: if the
|
||||
# native binary is missing but llama_cpp Python is already
|
||||
# installed, drop a wrapper at ~/bin/llama-server that
|
||||
# translates llama-server CLI args to llama_cpp.server's
|
||||
# underscore-style flags. The user's serve command stays
|
||||
# `llama-server ...` and "just works" — no build, no cmake,
|
||||
# no second install. This is the path that unblocks every
|
||||
# remote where pip-installed llama-cpp-python is already
|
||||
# working but Cookbook used to insist on a native binary.
|
||||
runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' mkdir -p ~/bin')
|
||||
runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'')
|
||||
runner_lines.append('#!/usr/bin/env bash')
|
||||
runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike')
|
||||
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
||||
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
||||
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
||||
runner_lines.append('ARGS=()')
|
||||
runner_lines.append('while [ $# -gt 0 ]; do')
|
||||
runner_lines.append(' case "$1" in')
|
||||
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
||||
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
||||
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
||||
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
||||
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
||||
runner_lines.append(' esac')
|
||||
runner_lines.append('done')
|
||||
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
||||
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
||||
runner_lines.append(' chmod +x ~/bin/llama-server')
|
||||
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' # If the native build failed, fall back to the Python bindings.')
|
||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||
# If the native build failed, fall back to the Python bindings.
|
||||
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
|
||||
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
_append_llama_server_finalization_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
elif re.search(r"\bollama\s+serve\b", req.cmd):
|
||||
handled_ollama_serve = True
|
||||
@@ -4343,7 +4302,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
tmux output can stop at a stale progress line if the pane/session
|
||||
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
||||
In that case, trust the cache shape: a snapshot directory with files
|
||||
and no *.incomplete blobs means HuggingFace finished materializing the
|
||||
and no unfinished blobs means HuggingFace finished materializing the
|
||||
model. cache_root is the task's custom download dir — the runner
|
||||
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
||||
not wherever this probe's environment says.
|
||||
@@ -4368,7 +4327,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"""Best-effort check for resumable HF partial blobs.
|
||||
|
||||
A lost SSH/tmux session can leave a real download still incomplete.
|
||||
Treat any *.incomplete blob as stronger evidence than stale
|
||||
Treat unfinished blobs (excluding empty markers paired with a
|
||||
completed blob) as stronger evidence than stale
|
||||
"100%" lines in the captured pane output.
|
||||
"""
|
||||
if not repo_id or "/" not in repo_id:
|
||||
|
||||
@@ -781,9 +781,11 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
|
||||
data = {}
|
||||
ids = data.get("ids") or []
|
||||
ids = data.get("ids") if isinstance(data, dict) else None
|
||||
if not ids:
|
||||
raise HTTPException(400, "No documents specified")
|
||||
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
||||
raise HTTPException(400, "ids must be a list of document ids")
|
||||
_ext = {
|
||||
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
|
||||
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
|
||||
|
||||
+162
-14
@@ -846,6 +846,64 @@ def _plan_psd_steps(prompt: str) -> Optional[List[Dict[str, Any]]]:
|
||||
return None
|
||||
|
||||
|
||||
def _is_background_removal_intent(prompt: str) -> bool:
|
||||
"""Linear-time, bounded intent parser for background removal commands.
|
||||
|
||||
Replaces the previous backtracking regular expression flagged by CodeQL
|
||||
(#1212) while preserving the exact command grammar.
|
||||
"""
|
||||
if not prompt or not isinstance(prompt, str):
|
||||
return False
|
||||
i = len(prompt)
|
||||
while i > 0 and prompt[i - 1] in ".! \t\r\n":
|
||||
i -= 1
|
||||
cleaned = prompt[:i].strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
|
||||
tokens = cleaned.split()
|
||||
if not tokens:
|
||||
return False
|
||||
|
||||
idx = 0
|
||||
if tokens[idx].lower() == "please":
|
||||
idx += 1
|
||||
if idx >= len(tokens):
|
||||
return False
|
||||
|
||||
if tokens[idx].lower() not in {"remove", "erase", "delete", "clear", "strip"}:
|
||||
return False
|
||||
idx += 1
|
||||
if idx >= len(tokens):
|
||||
return False
|
||||
|
||||
if tokens[idx].lower() == "the":
|
||||
idx += 1
|
||||
if idx >= len(tokens):
|
||||
return False
|
||||
|
||||
if tokens[idx].lower() not in {"bg", "background", "backdrop"}:
|
||||
return False
|
||||
idx += 1
|
||||
|
||||
if idx == len(tokens):
|
||||
return True
|
||||
|
||||
if tokens[idx].lower() in {"from", "of"}:
|
||||
idx += 1
|
||||
if idx < len(tokens) and tokens[idx].lower() == "the":
|
||||
idx += 1
|
||||
if (
|
||||
idx + 1 < len(tokens)
|
||||
and tokens[idx].lower() in {"active", "current", "selected"}
|
||||
and tokens[idx + 1].lower() == "layer"
|
||||
and idx + 2 == len(tokens)
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _plan_special(
|
||||
prompt: str, state: Any, provider_available: bool,
|
||||
) -> Optional[Tuple[List[Dict[str, Any]], str, str]]:
|
||||
@@ -855,6 +913,8 @@ def _plan_special(
|
||||
left to the model. Returns (steps, reply, note) or None. These never need an
|
||||
image provider except generative border fill, used only when one exists.
|
||||
"""
|
||||
if _is_background_removal_intent(prompt):
|
||||
return [{"op": "ai.removeBackground", "params": {}}], "Removing the active layer's background.", ""
|
||||
# Targeted recolour is fully deterministic (object mask + masked hue shift)
|
||||
# and must never be left to the model, which sometimes substitutes a
|
||||
# generative placeImage. Highest priority so it can never vary.
|
||||
@@ -1203,19 +1263,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:
|
||||
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 +1463,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":
|
||||
@@ -1598,6 +1707,13 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
screenshot = body.get("screenshot")
|
||||
# Optional user-picked image model (from the editor model selector).
|
||||
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
||||
generation_model_choice = str(body.get("generation_model") or "").strip()
|
||||
use_image_settings = body.get("_use_image_settings") is True
|
||||
if use_image_settings:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
image_models = editor_image_models(owner=owner)
|
||||
model_choice = image_models["edit"]
|
||||
generation_model_choice = image_models["generation"]
|
||||
# Optional user-picked PLANNER model (the editor agent/planner model).
|
||||
agent_model = str(body.get("agent_model") or "").strip()
|
||||
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||
@@ -1634,9 +1750,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 +1844,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")
|
||||
@@ -1737,9 +1864,22 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
# Planner output is untrusted: it cannot redirect this turn to
|
||||
# another editor session or override an explicit model choice.
|
||||
tool_payload["session"] = session
|
||||
if use_image_settings:
|
||||
tool_payload["_use_image_settings"] = True
|
||||
if op == "generate_image" and state.get("width") and state.get("height"):
|
||||
try:
|
||||
width, height = int(state["width"]), int(state["height"])
|
||||
if width > 0 and height > 0:
|
||||
tool_payload["size"] = f"{width}x{height}"
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
# Route to the user-picked model when one is selected.
|
||||
if model_choice:
|
||||
tool_payload["model"] = model_choice
|
||||
selected_model = generation_model_choice if op == "generate_image" else model_choice
|
||||
if selected_model:
|
||||
tool_payload["model"] = selected_model
|
||||
elif op == "generate_image" or use_image_settings:
|
||||
# Let server settings resolve Auto, not a planner-invented model.
|
||||
tool_payload.pop("model", None)
|
||||
# Hand the AI step the canvas image the client already captured
|
||||
# for this request. Without it the tool falls back to a separate
|
||||
# bridge screenshot, which is not reliably answered right after a
|
||||
@@ -1865,14 +2005,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 +2026,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 +2041,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 +2062,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,
|
||||
|
||||
@@ -19,9 +19,10 @@ def _split_family_token(token):
|
||||
def _derive_family(filename):
|
||||
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
|
||||
name = os.path.splitext(filename)[0]
|
||||
# Strip common weight/style suffixes
|
||||
# Strip weight/style suffixes, including BoldItalic and Bold-Italic.
|
||||
name = re.sub(
|
||||
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)$',
|
||||
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)'
|
||||
r'(?:[-_ ]?(?:Italic|Oblique))?$',
|
||||
'', name, flags=re.IGNORECASE
|
||||
)
|
||||
# Replace dashes/underscores with spaces
|
||||
|
||||
@@ -37,6 +37,24 @@ _SAM_STATE: Dict[str, Any] = {}
|
||||
_GROUNDING_STATE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _compose_cloud_image_edit(source, generated, mask, *, instruction=False):
|
||||
from PIL import Image, ImageChops, ImageOps
|
||||
|
||||
full_frame = instruction or mask.getextrema() == (255, 255)
|
||||
if full_frame:
|
||||
return generated, True
|
||||
# Partial edits must share the source coordinate system, but resizing
|
||||
# width/height independently distorts the subject when the provider changes ratio.
|
||||
fitted = ImageOps.contain(generated, source.size, Image.Resampling.LANCZOS)
|
||||
offset = ((source.width - fitted.width) // 2, (source.height - fitted.height) // 2)
|
||||
aligned = Image.new("RGBA", source.size)
|
||||
aligned.paste(fitted, offset)
|
||||
coverage = Image.new("L", source.size)
|
||||
coverage.paste(255, (offset[0], offset[1], offset[0] + fitted.width, offset[1] + fitted.height))
|
||||
effective_mask = ImageChops.multiply(mask, coverage)
|
||||
return Image.composite(aligned, source, effective_mask), False
|
||||
|
||||
|
||||
def _rss_mb() -> float:
|
||||
"""Current process RSS in MB (from /proc; -1.0 when unavailable)."""
|
||||
try:
|
||||
@@ -568,6 +586,7 @@ def _is_openai_api_base(url: str) -> bool:
|
||||
|
||||
|
||||
_GALLERY_ENDPOINT_PATHS = frozenset({
|
||||
"/chat/completions",
|
||||
"/images",
|
||||
"/images/edits",
|
||||
"/images/generations",
|
||||
@@ -683,9 +702,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:
|
||||
if model not in configured or not looks_like_image_generation_model(model):
|
||||
continue
|
||||
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target:
|
||||
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 +737,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 +1755,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,
|
||||
@@ -1739,6 +1764,17 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||
chosen_model = (body.pop("_model", "") or "").strip()
|
||||
if body.pop("_use_image_settings", False) is True:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
from src.ai_interaction import _resolve_model
|
||||
chosen_model = editor_image_models(owner=user)["edit"]
|
||||
if not chosen_model:
|
||||
raise HTTPException(400, "Choose an image edit model in AI settings")
|
||||
try:
|
||||
edit_url, chosen_model, _ = await asyncio.to_thread(_resolve_model, chosen_model, owner=user)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
requested_base = edit_url.removesuffix("/chat/completions").rstrip("/")
|
||||
# Optional client-supplied id so GET /api/image/edit-progress can poll the
|
||||
# provider's step progress for this op. Ignored by providers that do not
|
||||
# implement the progress contract.
|
||||
@@ -1856,13 +1892,29 @@ def setup_gallery_routes() -> APIRouter:
|
||||
mask_png.save(reference_mask, format="PNG")
|
||||
references = [src_buf.getvalue(), reference_mask.getvalue()]
|
||||
payload = {
|
||||
"model": oa_model, "size": size, "n": 1,
|
||||
"model": oa_model, "n": 1,
|
||||
"output_format": "png",
|
||||
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
|
||||
"input_references": [{"type": "image_url", "image_url": {
|
||||
"url": "data:image/png;base64," + base64.b64encode(value).decode(),
|
||||
}} for value in references],
|
||||
}
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
payload["aspect_ratio"] = image_aspect_ratio(source_png.width, source_png.height)
|
||||
from src.ai_interaction import _uses_openrouter_chat_images
|
||||
if _uses_openrouter_chat_images(oa_model):
|
||||
payload = {
|
||||
"model": oa_model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": payload["prompt"]},
|
||||
*[{"type": "image_url", "image_url": ref["image_url"]}
|
||||
for ref in payload["input_references"]],
|
||||
]}],
|
||||
"modalities": ["image", "text"], "stream": False,
|
||||
"image_config": {"aspect_ratio": payload["aspect_ratio"]},
|
||||
}
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/chat/completions"), headers=headers, json=payload)
|
||||
else:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
|
||||
else:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
||||
@@ -1879,6 +1931,12 @@ def setup_gallery_routes() -> APIRouter:
|
||||
pass
|
||||
raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})")
|
||||
result = r.json()
|
||||
from src.ai_interaction import _chat_image_results, _image_response_error
|
||||
provider_error = _image_response_error(result, oa_model)
|
||||
if provider_error:
|
||||
raise HTTPException(400 if "deprecated" in provider_error.lower() else 502, provider_error)
|
||||
if is_openrouter and _uses_openrouter_chat_images(oa_model):
|
||||
result = {"data": _chat_image_results(result)}
|
||||
raw_b64 = None
|
||||
if result.get("data"):
|
||||
item = result["data"][0]
|
||||
@@ -1898,17 +1956,13 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# the masked region actually changes.
|
||||
try:
|
||||
generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA")
|
||||
# Match the generated image to the source dims.
|
||||
if generated.size != source_png.size:
|
||||
generated = generated.resize(source_png.size, Image.LANCZOS)
|
||||
# mask_png: white = regenerate (use generated),
|
||||
# black = keep (use source).
|
||||
# Composite: result = source * (1 - mask_norm) + generated * mask_norm
|
||||
# Image.composite does exactly that with `mask`.
|
||||
blended = Image.composite(generated, source_png, mask_png)
|
||||
blended, full_frame = _compose_cloud_image_edit(
|
||||
source_png, generated, mask_png,
|
||||
instruction=body.get("mode") == "instruction",
|
||||
)
|
||||
out_buf = io.BytesIO()
|
||||
blended.save(out_buf, format="PNG")
|
||||
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model}
|
||||
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model, "full_frame": full_frame}
|
||||
except Exception as comp_err:
|
||||
# Never return a full-image edit when masking fails.
|
||||
logger.warning(f"Inpaint compose failed: {comp_err}")
|
||||
@@ -2076,7 +2130,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 +2223,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:
|
||||
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 +2292,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 +2319,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("/")
|
||||
@@ -2415,11 +2481,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
image_b64 = body.get("image")
|
||||
amount = body.get("amount", 50) / 100.0
|
||||
|
||||
from PIL import Image, ImageFilter
|
||||
from PIL import ImageFilter
|
||||
import base64, io
|
||||
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
img = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
|
||||
# Unsharp mask: radius=2, percent=amount*200, threshold=3
|
||||
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3))
|
||||
@@ -2454,8 +2519,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
except ImportError:
|
||||
raise HTTPException(500, "Server missing a required dependency")
|
||||
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
src = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
try:
|
||||
patch_realesrgan_torchvision_compat()
|
||||
from realesrgan import RealESRGANer
|
||||
@@ -2507,8 +2571,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
raise HTTPException(500, "Server missing a required dependency")
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
src = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
try:
|
||||
patch_realesrgan_torchvision_compat()
|
||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||
@@ -2556,7 +2619,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
height = int((body or {}).get("height") or 0)
|
||||
except (TypeError, ValueError):
|
||||
width = height = 0
|
||||
if height > width:
|
||||
if width > 0 and height > 0:
|
||||
size = f"{width}x{height}"
|
||||
elif height > width:
|
||||
size = "1024x1536"
|
||||
elif width > height:
|
||||
size = "1536x1024"
|
||||
@@ -2569,6 +2634,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# The editor's model picker may choose a specific (cloud) image model;
|
||||
# forward it so settings defaults only apply when none is selected.
|
||||
model_choice = str((body or {}).get("model") or "").strip()
|
||||
if body.get("_use_image_settings") is True:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
model_choice = editor_image_models(owner=user)["generation"]
|
||||
payload = {"prompt": prompt, "size": size}
|
||||
if model_choice:
|
||||
payload["model"] = model_choice
|
||||
@@ -2760,8 +2828,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
from PIL import Image
|
||||
import base64, io
|
||||
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
|
||||
img = _b64_to_pil_image(image_b64)
|
||||
W, H = img.size
|
||||
|
||||
hint = None
|
||||
@@ -2850,8 +2917,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
from PIL import Image, ImageFilter, ImageEnhance
|
||||
import numpy as np
|
||||
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
img = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
|
||||
# Try GFPGAN first (AI face restoration)
|
||||
try:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -728,32 +728,35 @@ def setup_note_routes(task_scheduler=None, upload_handler=None):
|
||||
)
|
||||
if body.title is not None:
|
||||
note.title = body.title
|
||||
if body.content is not None:
|
||||
# Omitted fields preserve their values; explicit null clears the
|
||||
# nullable fields used by the editor's removal controls.
|
||||
supplied = body.model_fields_set
|
||||
if "content" in supplied:
|
||||
note.content = body.content
|
||||
if body.items is not None:
|
||||
note.items = json.dumps(body.items)
|
||||
if "items" in supplied:
|
||||
note.items = json.dumps(body.items) if body.items is not None else None
|
||||
flag_modified(note, "items")
|
||||
if body.note_type is not None:
|
||||
note.note_type = body.note_type
|
||||
if body.color is not None:
|
||||
if "color" in supplied:
|
||||
note.color = body.color
|
||||
if body.label is not None:
|
||||
if "label" in supplied:
|
||||
note.label = body.label
|
||||
if body.pinned is not None:
|
||||
note.pinned = body.pinned
|
||||
if body.archived is not None:
|
||||
note.archived = body.archived
|
||||
if body.due_date is not None:
|
||||
if "due_date" in supplied:
|
||||
note.due_date = body.due_date
|
||||
if body.image_url is not None:
|
||||
if "image_url" in supplied:
|
||||
note.image_url = body.image_url
|
||||
if body.gallery_id is not None:
|
||||
if "gallery_id" in supplied:
|
||||
note.gallery_id = body.gallery_id
|
||||
if body.repeat is not None:
|
||||
note.repeat = body.repeat
|
||||
if body.sort_order is not None:
|
||||
note.sort_order = body.sort_order
|
||||
if body.agent_session_id is not None:
|
||||
if "agent_session_id" in supplied:
|
||||
note.agent_session_id = body.agent_session_id
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -118,8 +118,12 @@ def setup_prefs_routes():
|
||||
async def set_pref(request: Request, key: str, body: dict):
|
||||
user = get_current_user(request)
|
||||
prefs = _load_for_user(user)
|
||||
prefs[key] = body.get("value")
|
||||
val = body.get("value")
|
||||
if val is None:
|
||||
prefs.pop(key, None)
|
||||
else:
|
||||
prefs[key] = val
|
||||
_save_for_user(user, prefs)
|
||||
return {"key": key, "value": prefs[key]}
|
||||
return {"key": key, "value": prefs.get(key)}
|
||||
|
||||
return router
|
||||
|
||||
@@ -1315,7 +1315,7 @@ def setup_session_routes(
|
||||
def auto_sort_sessions(request: Request, skip_llm: bool = False):
|
||||
"""Use AI to categorize all sessions into folders.
|
||||
|
||||
Phase 1 deletes empty/throwaway sessions and Phase 2 asks the LLM
|
||||
Phase 1 deletes empty/Incognito sessions and Phase 2 asks the LLM
|
||||
to assign folders. When `skip_llm=true` the endpoint returns
|
||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||
users can clean junk without spending tokens.
|
||||
@@ -1326,21 +1326,11 @@ def setup_session_routes(
|
||||
single_user_mode = not user and _auth_disabled()
|
||||
user_sessions = session_manager.get_sessions_for_user(user)
|
||||
|
||||
# Delete empty and throwaway sessions before sorting
|
||||
# Saved conversations are never disposable based on their title or prompt.
|
||||
from core.database import ChatMessage as DbMsg
|
||||
db = SessionLocal()
|
||||
deleted_empty = 0
|
||||
deleted_throwaway = 0
|
||||
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
|
||||
_THROWAWAY_NAMES = {
|
||||
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
|
||||
"yo", "sup", "hola", "hii", "hiii", "heyo",
|
||||
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
|
||||
"new chat", "delete", "remove", "junk", "trash", "xxx",
|
||||
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
|
||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
||||
}
|
||||
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
|
||||
try:
|
||||
rows_q = db.query(DbSession).filter(DbSession.archived == False)
|
||||
if user:
|
||||
@@ -1349,15 +1339,11 @@ def setup_session_routes(
|
||||
rows_q = rows_q.filter(DbSession.owner == user)
|
||||
rows = rows_q.limit(2000).all()
|
||||
folder_map = {r.id: r.folder for r in rows}
|
||||
# Precompute per-session message counts in TWO aggregate queries
|
||||
# Precompute per-session message counts in one aggregate query
|
||||
# instead of 1–3 queries PER session — with many chats the per-row
|
||||
# loop was doing thousands of round-trips and blowing the timeout.
|
||||
from sqlalchemy import func as _sa_func
|
||||
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
|
||||
_asst_counts = dict(
|
||||
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
|
||||
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
|
||||
)
|
||||
cleanup_now = utcnow_naive()
|
||||
for row in rows:
|
||||
# Never delete important sessions
|
||||
@@ -1367,9 +1353,10 @@ def setup_session_routes(
|
||||
if (row.name or "").strip() == "Incognito":
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
db.delete(row)
|
||||
if hasattr(session_manager, 'delete_session'):
|
||||
session_manager.delete_session(row.id)
|
||||
else:
|
||||
db.delete(row)
|
||||
continue
|
||||
if is_session_recently_active(row, now=cleanup_now):
|
||||
continue
|
||||
@@ -1378,31 +1365,11 @@ def setup_session_routes(
|
||||
if msg_count == 0:
|
||||
should_delete = True
|
||||
deleted_empty += 1
|
||||
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
|
||||
name = (row.name or "").strip().lower()
|
||||
# Check first user message content (AI renames sessions, so
|
||||
# "hi" becomes "Casual Greeting Exchange" — name alone won't match)
|
||||
first_msg = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
||||
).order_by(DbMsg.timestamp).first()
|
||||
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
|
||||
# Count assistant messages — if user sent something but AI never replied, it's dead
|
||||
assistant_count = _asst_counts.get(row.id, 0)
|
||||
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
# Single user message with no AI response = dead session
|
||||
elif msg_count == 1 and assistant_count == 0:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
# Short phrase (1-3 words) with no real AI conversation (<=2 msgs)
|
||||
elif msg_count <= 2 and first_text and len(first_text.split()) <= 3 and len(first_text) <= 40:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
if should_delete:
|
||||
db.delete(row)
|
||||
if hasattr(session_manager, 'delete_session'):
|
||||
session_manager.delete_session(row.id)
|
||||
else:
|
||||
db.delete(row)
|
||||
if deleted_empty or deleted_throwaway:
|
||||
db.commit()
|
||||
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
|
||||
|
||||
@@ -20,7 +20,7 @@ from src.task_action_policy import (
|
||||
is_admin_only_task_action,
|
||||
owner_has_admin_task_privileges,
|
||||
)
|
||||
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
|
||||
from src.task_scheduler import compute_next_run, is_valid_cron, HOUSEKEEPING_DEFAULTS
|
||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -475,10 +475,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
|
||||
raise HTTPException(400, "Cron expression is required for cron schedule")
|
||||
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(req.cron_expression)
|
||||
except Exception:
|
||||
if not is_valid_cron(req.cron_expression):
|
||||
raise HTTPException(400, "Invalid cron expression")
|
||||
if req.trigger_type == "event" and not req.trigger_event:
|
||||
raise HTTPException(400, "Event name is required for event-triggered tasks")
|
||||
@@ -788,10 +785,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
task.character_id = req.character_id or None
|
||||
if req.cron_expression is not None:
|
||||
if req.cron_expression:
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(req.cron_expression)
|
||||
except Exception:
|
||||
if not is_valid_cron(req.cron_expression):
|
||||
raise HTTPException(400, "Invalid cron expression")
|
||||
task.cron_expression = req.cron_expression or None
|
||||
|
||||
|
||||
+73
-27
@@ -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,35 +453,62 @@ 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:
|
||||
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
|
||||
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)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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())
|
||||
@@ -6,6 +6,7 @@ that are awkward at the shell). Filters images by tag, prompt, favorite,
|
||||
album, and surfaces EXIF metadata.
|
||||
|
||||
odysseus-gallery list --limit 20 --favorites
|
||||
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
|
||||
odysseus-gallery show IMAGE_ID
|
||||
odysseus-gallery albums | jq -r '.[] | .name'
|
||||
odysseus-gallery search "sunset" # matches prompt + tags
|
||||
@@ -83,7 +84,13 @@ def cmd_list(args):
|
||||
fail(f"no album named {args.album!r}")
|
||||
q = q.filter(GalleryImage.album_id == al.id)
|
||||
if args.tag:
|
||||
q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
|
||||
for tag in (value.strip() for value in args.tag.split(",")):
|
||||
if not tag:
|
||||
continue
|
||||
q = q.filter(
|
||||
GalleryImage.tags.ilike(f"%{tag}%") |
|
||||
GalleryImage.ai_tags.ilike(f"%{tag}%")
|
||||
)
|
||||
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
|
||||
emit([_serialize_image(i) for i in q.all()], args)
|
||||
finally:
|
||||
@@ -159,7 +166,7 @@ def _build_parser():
|
||||
pl.add_argument("--limit", type=int, default=50)
|
||||
pl.add_argument("--favorites", action="store_true")
|
||||
pl.add_argument("--album", help="filter by album name")
|
||||
pl.add_argument("--tag", help="substring match against tags column")
|
||||
pl.add_argument("--tag", help="comma-separated tags; each matches manual or AI tags by substring")
|
||||
pl.set_defaults(func=cmd_list)
|
||||
|
||||
psh = sub.add_parser("show", parents=[common])
|
||||
|
||||
+12
-6
@@ -43,7 +43,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Anchor the path so the script works when invoked from anywhere.
|
||||
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
|
||||
emit([], args)
|
||||
return
|
||||
all_uids = data[0].split()
|
||||
# Newest first, limit
|
||||
# Keep a bounded arrival batch; order its decoded rows by Date below.
|
||||
uids = list(reversed(all_uids))[: args.limit]
|
||||
out = []
|
||||
for uid in uids:
|
||||
@@ -165,10 +165,15 @@ def cmd_list(args) -> None:
|
||||
sender_name, sender_addr = parseaddr(sender)
|
||||
date_raw = msg.get("Date", "")
|
||||
try:
|
||||
iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
|
||||
parsed_date = parsedate_to_datetime(date_raw) if date_raw else None
|
||||
iso = parsed_date.isoformat() if parsed_date else ""
|
||||
if parsed_date is not None and parsed_date.tzinfo is None:
|
||||
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
|
||||
date_epoch = parsed_date.timestamp() if parsed_date else float("-inf")
|
||||
except Exception:
|
||||
iso = ""
|
||||
out.append({
|
||||
date_epoch = float("-inf")
|
||||
out.append((date_epoch, {
|
||||
"uid": uid.decode(),
|
||||
"date": iso,
|
||||
"from_addr": sender_addr,
|
||||
@@ -176,10 +181,11 @@ def cmd_list(args) -> None:
|
||||
"subject": subject,
|
||||
"is_read": "\\Seen" in flags,
|
||||
"is_answered": "\\Answered" in flags,
|
||||
})
|
||||
}))
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
|
||||
emit(out, args)
|
||||
out.sort(key=lambda item: item[0], reverse=True)
|
||||
emit([row for _, row in out], args)
|
||||
|
||||
|
||||
# ─── read ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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]} <results_directory>")
|
||||
sys.exit(1)
|
||||
|
||||
target_dir = sys.argv[1]
|
||||
sanitize_directory(target_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,6 +11,38 @@ import time
|
||||
from urllib.request import urlopen
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def _stop_owned_server(server, identity):
|
||||
"""Close only identity-bound descendants, including MCP's new sessions.
|
||||
|
||||
Capture before signalling the parent: an orphaned stderr holder can no
|
||||
longer be attributed by ancestry. Never search by executable name or kill
|
||||
the caller's process group.
|
||||
"""
|
||||
from src import process_lifecycle as lifecycle
|
||||
|
||||
descendants = (lifecycle.bind_descendants([identity.pid])
|
||||
if identity.verdict() == lifecycle.OWNED else [])
|
||||
if os.name == "nt":
|
||||
if identity.verdict() == lifecycle.OWNED:
|
||||
lifecycle.taskkill_tree(identity.pid)
|
||||
else:
|
||||
outcome = lifecycle.terminate_identities(
|
||||
[*(entry.identity for entry in reversed(descendants)), identity],
|
||||
steps=lifecycle.term_kill_steps(10),
|
||||
)
|
||||
if outcome.survivors or outcome.unverified:
|
||||
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
|
||||
try:
|
||||
logs, _ = server.communicate(timeout=2)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
# Do not turn an unidentified pipe holder into an unbounded wait.
|
||||
server.stdout.close()
|
||||
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
|
||||
return logs
|
||||
|
||||
|
||||
def main():
|
||||
@@ -36,7 +68,10 @@ def main():
|
||||
server = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
|
||||
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
start_new_session=(os.name != "nt"),
|
||||
)
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
server_identity = ProcessIdentity.capture(server.pid)
|
||||
result = None
|
||||
try:
|
||||
origin = f"http://127.0.0.1:{port}"
|
||||
@@ -62,12 +97,7 @@ def main():
|
||||
print(result.stderr, file=sys.stderr)
|
||||
return result.returncode
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
logs, _ = server.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
logs, _ = server.communicate()
|
||||
logs = _stop_owned_server(server, server_identity)
|
||||
if result is None or result.returncode:
|
||||
print(logs[-8000:], file=sys.stderr)
|
||||
|
||||
|
||||
@@ -202,17 +202,35 @@ def _infer_use_case(repo_id, pipeline_tag):
|
||||
return "general"
|
||||
|
||||
|
||||
# HF collections mix in models no Cookbook LLM backend can serve (vision
|
||||
# encoders, time-series forecasters, image/video generators). Image models
|
||||
# have their own registry, so keep these out of the LLM catalog.
|
||||
_NON_LLM_PIPELINE_TAGS = frozenset({
|
||||
"time-series-forecasting",
|
||||
"zero-shot-image-classification",
|
||||
"image-classification",
|
||||
"image-feature-extraction",
|
||||
"text-to-image",
|
||||
"image-to-video",
|
||||
"text-to-video",
|
||||
})
|
||||
|
||||
|
||||
def _entry_from_collection_item(collection, item, source):
|
||||
repo_id = item.get("id") or ""
|
||||
if item.get("type") != "model" or not repo_id:
|
||||
return None
|
||||
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
|
||||
return None
|
||||
repo_prefix = source.get("repo_prefix")
|
||||
if repo_prefix and not repo_id.startswith(repo_prefix):
|
||||
return None
|
||||
raw_params = item.get("numParameters") or 0
|
||||
active = None
|
||||
# Read the name even when HF gives a total: it is the only source of the
|
||||
# MoE active size (e.g. "-35B-A3B"), which the speed estimate depends on.
|
||||
name_params, active = _parse_params_from_name(repo_id)
|
||||
if not raw_params:
|
||||
raw_params, active = _parse_params_from_name(repo_id)
|
||||
raw_params = name_params
|
||||
param_label, raw_params = _format_params(raw_params)
|
||||
if not raw_params:
|
||||
return None
|
||||
|
||||
@@ -84,10 +84,14 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE)
|
||||
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
|
||||
|
||||
|
||||
def _parse_scalar(raw: str) -> Any:
|
||||
def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
|
||||
raw = raw.strip()
|
||||
if raw == "":
|
||||
return ""
|
||||
# String fields keep their spelling; quoted values still use the decoder
|
||||
# below so existing escapes round-trip as before.
|
||||
if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')):
|
||||
return raw
|
||||
if raw.startswith("[") and raw.endswith("]"):
|
||||
inner = raw[1:-1].strip()
|
||||
if not inner:
|
||||
@@ -171,7 +175,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
pending_key = key
|
||||
fm[key] = []
|
||||
else:
|
||||
fm[key] = _parse_scalar(val)
|
||||
fm[key] = _parse_scalar(val, string_only=(key == "version"))
|
||||
pending_key = None
|
||||
continue
|
||||
m2 = _FM_BLOCK_LIST_RE.match(line)
|
||||
|
||||
+2
-2
@@ -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-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
|
||||
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
|
||||
"manage_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": "<short todo>", "due_date": "<natural language or ISO datetime>"}
|
||||
|
||||
@@ -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"],
|
||||
@@ -754,6 +778,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
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. "
|
||||
"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]
|
||||
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"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),
|
||||
|
||||
@@ -191,7 +191,7 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
|
||||
db.close()
|
||||
|
||||
|
||||
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> bool:
|
||||
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None, native_size: bool = False) -> bool:
|
||||
params: Dict[str, Any] = {
|
||||
"dataUrl": data_url,
|
||||
"name": name,
|
||||
@@ -199,6 +199,8 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
|
||||
"blend": blend or "normal",
|
||||
"opacity": opacity,
|
||||
}
|
||||
if native_size:
|
||||
params["nativeSize"] = True
|
||||
# Forward the edit mode so the client can build a difference mask for
|
||||
# instruction-mode results: the whole image was regenerated under an
|
||||
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
||||
@@ -506,6 +508,8 @@ class AiEditImageTool:
|
||||
}
|
||||
_t["image"] = time.perf_counter()
|
||||
payload: Dict[str, Any] = {"image": image}
|
||||
if args.get("_use_image_settings") is True:
|
||||
payload["_use_image_settings"] = True
|
||||
model_choice = str(args.get("model") or "").strip()
|
||||
mask_used = False
|
||||
place_mode: Optional[str] = None
|
||||
@@ -609,10 +613,11 @@ class AiEditImageTool:
|
||||
f"AI {op}",
|
||||
owner,
|
||||
session,
|
||||
masked=mask_used,
|
||||
masked=mask_used and data.get("full_frame") is not True,
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
mode=place_mode,
|
||||
native_size=data.get("full_frame") is True,
|
||||
)
|
||||
info["placed"] = placed
|
||||
if placed:
|
||||
@@ -687,6 +692,7 @@ class GenerateImageLayerTool:
|
||||
str(args.get("name") or "Generated image"),
|
||||
owner,
|
||||
session,
|
||||
native_size=True,
|
||||
masked=bool(args.get("mask")),
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
|
||||
+135
-22
@@ -179,8 +179,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[
|
||||
# served internal id. Include pinned/cached ids in the match set
|
||||
# so chat sessions using the HF repo id still resolve. Do not use
|
||||
# stale cached aliases when the endpoint itself is unreachable.
|
||||
if model_type == "image" and endpoint_reachable:
|
||||
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
|
||||
if endpoint_reachable:
|
||||
# Gateway image models may live in a dedicated catalogue,
|
||||
# so explicitly pinned IDs are valid on mixed endpoints too.
|
||||
extras = _json_list(getattr(ep, "pinned_models", None))
|
||||
if model_type == "image":
|
||||
extras += _json_list(getattr(ep, "cached_models", None))
|
||||
for extra in extras:
|
||||
if extra not in model_ids:
|
||||
model_ids.append(extra)
|
||||
|
||||
@@ -1176,9 +1181,9 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||
request on this so it never falls back to deterministic approximations.
|
||||
"""
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
from src.settings import get_user_setting, load_settings
|
||||
|
||||
if not load_settings().get("image_gen_enabled", True):
|
||||
if not load_settings().get("image_gen_enabled", True) or not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1200,6 +1205,39 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _uses_openrouter_chat_images(model_id: str) -> bool:
|
||||
# GPT-5 Image is an LLM wrapper, unlike native gpt-image-* image models.
|
||||
leaf = model_id_leaf(model_id).lower()
|
||||
return leaf.startswith("gpt-") and "-image" in leaf and not leaf.startswith("gpt-image-")
|
||||
|
||||
|
||||
def _chat_image_results(data: dict) -> list:
|
||||
images = []
|
||||
for choice in data.get("choices") or []:
|
||||
message = choice.get("message") or {}
|
||||
for item in message.get("images") or []:
|
||||
url = (item.get("image_url") or {}).get("url")
|
||||
if not isinstance(url, str):
|
||||
continue
|
||||
if url.startswith("data:image/") and ";base64," in url:
|
||||
images.append({"b64_json": url.split(",", 1)[1]})
|
||||
elif url.startswith(("https://", "http://")):
|
||||
images.append({"url": url})
|
||||
return images
|
||||
|
||||
|
||||
def _image_response_error(data: dict, model_id: str) -> Optional[str]:
|
||||
errors = [data.get("error")]
|
||||
errors.extend(choice.get("error") for choice in data.get("choices") or [])
|
||||
for error in errors:
|
||||
if not error:
|
||||
continue
|
||||
message = str(error.get("message") or "Image provider failed") if isinstance(error, dict) else str(error)
|
||||
hint = " Choose a currently supported image model in AI settings." if "deprecated" in message.lower() else ""
|
||||
return f"Image provider failed for '{model_id}': {message}{hint}"
|
||||
return None
|
||||
|
||||
|
||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||
|
||||
@@ -1207,7 +1245,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
Line 1: prompt describing the image
|
||||
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
|
||||
Line 3: size (optional, defaults to 1024x1024)
|
||||
Line 4: quality (optional, defaults to medium — options: low, medium, high, auto)
|
||||
Line 4: quality (optional, defaults to configured quality or medium — low, medium, high, auto)
|
||||
"""
|
||||
import base64
|
||||
import httpx
|
||||
@@ -1225,32 +1263,38 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
prompt = str(args.get('prompt') or '').strip()
|
||||
model_spec = str(args.get('model') or '').strip()
|
||||
size = str(args.get('size') or '1024x1024')
|
||||
quality = str(args.get('quality') or 'medium')
|
||||
quality = str(args.get('quality') or '')
|
||||
else:
|
||||
lines = content.strip().split("\n")
|
||||
prompt = lines[0].strip() if lines else ""
|
||||
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
|
||||
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
|
||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
|
||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else ""
|
||||
|
||||
if not prompt:
|
||||
return {"error": "Image prompt is required (line 1)"}
|
||||
|
||||
# Load admin settings for defaults
|
||||
# Load settings for defaults
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
from src.settings import get_user_setting, load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_settings = {}
|
||||
def get_user_setting(key, owner="", default=None):
|
||||
return default
|
||||
|
||||
# The administrator's global image-generation disable switch is authoritative.
|
||||
# Non-admin preferences cannot re-enable generation when disabled globally.
|
||||
if not _settings.get("image_gen_enabled", True):
|
||||
return {"error": "Image generation is disabled by the administrator."}
|
||||
if not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||
return {"error": "Image generation is disabled by user preference."}
|
||||
|
||||
# Use admin-configured model/quality if not specified by the tool call
|
||||
# Use user- or admin-configured model/quality if not specified by the tool call
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
model_spec = get_user_setting("image_model", owner=owner, default="") or ""
|
||||
if not quality:
|
||||
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||
|
||||
# Auto-detect best available image model if still not set
|
||||
if not model_spec:
|
||||
@@ -1342,8 +1386,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||
images_url = base_url + "/images/generations"
|
||||
from src.model_capability_readers.base import detect_vendor
|
||||
chat_images = detect_vendor(url) == "openrouter" and _uses_openrouter_chat_images(model_id)
|
||||
if detect_vendor(url) == "openrouter":
|
||||
images_url = base_url + "/images"
|
||||
if chat_images:
|
||||
images_url = base_url + "/chat/completions"
|
||||
|
||||
# Capture the requested geometry before older OpenAI size restrictions.
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
try:
|
||||
requested_width, requested_height = (int(value) for value in size.lower().split("x"))
|
||||
requested_ratio = image_aspect_ratio(requested_width, requested_height)
|
||||
except (TypeError, ValueError):
|
||||
requested_ratio = "1:1"
|
||||
|
||||
# Validate size for cloud image models (local diffusion accepts any WxH)
|
||||
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
||||
@@ -1359,6 +1414,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
"n": 1,
|
||||
"size": size,
|
||||
}
|
||||
if detect_vendor(url) == "openrouter":
|
||||
payload.pop("size", None)
|
||||
payload["aspect_ratio"] = requested_ratio
|
||||
|
||||
# GPT image models and local diffusion support quality; DALL-E does not
|
||||
if is_gpt_image or is_local_diffusion:
|
||||
@@ -1372,7 +1430,16 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
try:
|
||||
# GPT image models can take 30-120s+ depending on quality
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
|
||||
resp = await client.post(images_url, json=payload, headers=headers)
|
||||
request_payload = payload
|
||||
if chat_images:
|
||||
request_payload = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"modalities": ["image", "text"],
|
||||
"stream": False,
|
||||
"image_config": {"aspect_ratio": requested_ratio},
|
||||
}
|
||||
resp = await client.post(images_url, json=request_payload, headers=headers)
|
||||
|
||||
if resp.status_code != 200:
|
||||
error_text = resp.text[:500]
|
||||
@@ -1387,9 +1454,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
provider_error = _image_response_error(data, model_id)
|
||||
if provider_error:
|
||||
return {"error": provider_error, "untrusted_content": True}
|
||||
images = _chat_image_results(data) if chat_images else data.get("data", [])
|
||||
if not images:
|
||||
return {"error": "No images returned from API"}
|
||||
return {"error": (
|
||||
f"Image model '{model_id}' returned no image. "
|
||||
"Choose another image generation model in AI settings."
|
||||
)}
|
||||
|
||||
img = images[0]
|
||||
image_url = None
|
||||
@@ -1482,7 +1555,7 @@ async def do_edit_image(
|
||||
session_id: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
size: str = "auto",
|
||||
quality: str = "medium",
|
||||
quality: Optional[str] = None,
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
incognito: bool = False,
|
||||
) -> Dict:
|
||||
@@ -1502,21 +1575,27 @@ async def do_edit_image(
|
||||
return {"error": "Attached image file was not found"}
|
||||
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
from src.settings import get_user_setting, load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_settings = {}
|
||||
def get_user_setting(key, owner="", default=None):
|
||||
return default
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
model_spec = get_user_setting("image_edit_model", owner=owner) or get_user_setting("image_model", owner=owner, default="")
|
||||
if quality is None:
|
||||
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||
if not model_spec:
|
||||
return {"error": "No image model selected for image editing"}
|
||||
|
||||
try:
|
||||
try:
|
||||
def _call():
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
@@ -1529,6 +1608,7 @@ async def do_edit_image(
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||
|
||||
requested_edit_size = size
|
||||
if not size or size == "auto":
|
||||
from PIL import Image
|
||||
from src.image_model_ids import image_edit_size
|
||||
@@ -1704,6 +1784,34 @@ async def do_edit_image(
|
||||
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
||||
}],
|
||||
}
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
from PIL import Image as ReferenceImage
|
||||
with ReferenceImage.open(path) as reference:
|
||||
reference_width, reference_height = reference.size
|
||||
if reference.getexif().get(274) in {5, 6, 7, 8}:
|
||||
reference_width, reference_height = reference_height, reference_width
|
||||
if requested_edit_size and requested_edit_size != "auto":
|
||||
try:
|
||||
reference_width, reference_height = (int(value) for value in requested_edit_size.lower().split("x"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
edit_payload.pop("size", None)
|
||||
edit_payload["aspect_ratio"] = image_aspect_ratio(reference_width, reference_height)
|
||||
if _uses_openrouter_chat_images(model_id):
|
||||
edit_payload = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {
|
||||
"url": f"data:{mime};base64,{image_b64}",
|
||||
}},
|
||||
]}],
|
||||
"modalities": ["image", "text"],
|
||||
"stream": False,
|
||||
"image_config": {"aspect_ratio": edit_payload["aspect_ratio"]},
|
||||
}
|
||||
resp = await client.post(base_url + "/chat/completions", json=edit_payload, headers=headers)
|
||||
else:
|
||||
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
||||
else:
|
||||
with path.open("rb") as f:
|
||||
@@ -1747,7 +1855,12 @@ async def do_edit_image(
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
provider_error = _image_response_error(data, model_id)
|
||||
if provider_error:
|
||||
return {"error": provider_error, "untrusted_content": True}
|
||||
images = (_chat_image_results(data)
|
||||
if is_openrouter and _uses_openrouter_chat_images(model_id)
|
||||
else data.get("data", []))
|
||||
if not images:
|
||||
return {"error": "No image returned from edit API"}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ def compact_browser_observation(value, budget=8000):
|
||||
# Snapshot text already contains labels and refs in DOM order.
|
||||
# The refs mapping repeats them and buries menus in raw JSON.
|
||||
lines = [line for line in snapshot.splitlines()
|
||||
if not re.fullmatch(r'\s*-?\s*generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
||||
if not re.fullmatch(r'\s*(?:-\s*)?generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
||||
pages.append(('URL: ' + str(url) + '\n' if url else '') + '\n'.join(lines))
|
||||
elif url:
|
||||
notices.append('URL: ' + str(url))
|
||||
@@ -37,7 +37,8 @@ def compact_browser_observation(value, budget=8000):
|
||||
parsed = json.loads(item)
|
||||
except (ValueError, TypeError):
|
||||
# CLI status text precedes the JSON post-interaction state.
|
||||
parts = re.split(r'\n\n\[(?:post-[^\]]+|page state after failed [^\]]+)\]\n', item)
|
||||
# Labels are one line; stopping at \n keeps the split linear.
|
||||
parts = re.split(r'\n\n\[(?:post-[^\]\n]+|page state after failed [^\]\n]+)\]\n', item)
|
||||
if len(parts) > 1:
|
||||
for part in parts:
|
||||
visit(part)
|
||||
|
||||
@@ -1229,7 +1229,7 @@ def tasks_terminal_response(raw, *, user_text='', max_items=20):
|
||||
text = str(payload or '').strip()
|
||||
rows = []
|
||||
for line in text.splitlines():
|
||||
match = re.match(r'^\s*\d+\.\s+(.*?)\s+\(([^)]+)\)\s+—\s+(.+)$', line)
|
||||
match = re.match(r'^\s*\d+\.\s(.*?)\s\(([^()]+)\)\s+—\s+(.+)$', line)
|
||||
if match:
|
||||
details = match[3].strip()
|
||||
rows.append({
|
||||
@@ -1346,7 +1346,7 @@ def skills_terminal_response(raw, *, user_text='', max_items=20):
|
||||
continue
|
||||
match = re.match(r'^\s*-\s+\*\*([^*]+)\*\*(?:\s+\(([^)]+)\))?', line)
|
||||
if not match:
|
||||
match = re.match(r'^\s*-\s+([^:]+?)(?:\s+\(([^)]+)\))?(?:\s*:|$)', line)
|
||||
match = re.match(r'^\s*-\s+([^\s:][^:]*?)(?:\s\(([^()]+)\)(?:\s*:|$)|:|$)', line)
|
||||
if match and status:
|
||||
rows.append((status, match.group(1).strip(), (match.group(2) or '').strip()))
|
||||
if not rows:
|
||||
@@ -5065,7 +5065,7 @@ def email_search_result_empty(value):
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return bool(re.fullmatch(r'\s*No emails matched [^\n]+\.?\s*', value, re.I))
|
||||
return bool(re.fullmatch(r'\s*No emails matched [^\n]+(?:\n\s*)?', value, re.I))
|
||||
if decoded == value:
|
||||
return False
|
||||
value = decoded
|
||||
@@ -5339,6 +5339,16 @@ def private_browser_effective_url(result):
|
||||
return urls[-1] if urls else ''
|
||||
|
||||
|
||||
def is_google_sorry_url(url):
|
||||
"""Recognize Google's rate-limit interstitial."""
|
||||
# Matching the host separately keeps this linear; one combined pattern
|
||||
# rescans the host from every dot.
|
||||
return any(
|
||||
re.fullmatch(r'(?:[^/]+\.)?google\.[^/]+', host[1], re.I)
|
||||
for host in re.finditer(r'https?://([^/]+)/sorry/', url, re.I)
|
||||
)
|
||||
|
||||
|
||||
def browser_observation_page_missing(raw):
|
||||
"""Recognize a rendered error page, rather than an article mentioning 404."""
|
||||
text = str(raw or '')
|
||||
@@ -8213,11 +8223,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
browser_search_url = requested_browser_url or effective_browser_url
|
||||
is_search_engine_navigation = contains_search_engine_navigation(
|
||||
browser_search_url
|
||||
) or bool(re.search(
|
||||
r'https?://(?:[^/]+\.)?google\.[^/]+/sorry/',
|
||||
effective_browser_url,
|
||||
re.I,
|
||||
))
|
||||
) or is_google_sorry_url(effective_browser_url)
|
||||
has_native_search = any(
|
||||
canonical(schema['function']['name']) == 'web_search'
|
||||
for schema in offered
|
||||
|
||||
+239
-10
@@ -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:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""The shared model defaults used by Quick Edit's image operations."""
|
||||
from src.settings import get_user_setting, load_settings
|
||||
|
||||
|
||||
def editor_image_models(owner: str = "") -> dict[str, str]:
|
||||
settings = load_settings()
|
||||
generation = ""
|
||||
edit_model = ""
|
||||
if owner:
|
||||
generation = str(get_user_setting("image_model", owner=owner, default="") or "").strip()
|
||||
edit_model = str(get_user_setting("image_edit_model", owner=owner, default="") or "").strip()
|
||||
if not generation:
|
||||
generation = str(settings.get("image_model") or "").strip()
|
||||
if not edit_model:
|
||||
edit_model = str(settings.get("image_edit_model") or generation).strip()
|
||||
return {
|
||||
"generation": generation,
|
||||
"edit": edit_model or generation,
|
||||
}
|
||||
@@ -40,6 +40,15 @@ def image_edit_size(model_id: str, width: int, height: int) -> str:
|
||||
return f"{width}x{height}"
|
||||
|
||||
|
||||
def image_aspect_ratio(width: int, height: int) -> str:
|
||||
"""Choose the nearest common Image API ratio, without distorting pixels."""
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("Image dimensions must be positive")
|
||||
ratios = ((1, 1), (16, 9), (9, 16), (3, 2), (2, 3), (4, 3), (3, 4), (21, 9))
|
||||
w, h = min(ratios, key=lambda ratio: abs(math.log((ratio[0] / ratio[1]) / (width / height))))
|
||||
return f"{w}:{h}"
|
||||
|
||||
|
||||
def looks_like_image_generation_model(model_id: str) -> bool:
|
||||
"""Return True when a model id should use image generation routes.
|
||||
|
||||
|
||||
+52
-13
@@ -422,7 +422,18 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
_DEGENERATE_WORD_RE = re.compile(r"[A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff]+")
|
||||
_WORD_CHARS = r"A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff"
|
||||
_DELIM_RE = re.compile(rf"[^{_WORD_CHARS}+\-.]")
|
||||
_NUMERIC_PATTERN = rf"(?<![{_WORD_CHARS}])[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?(?![{_WORD_CHARS}])"
|
||||
_NUMERIC_FULLMATCH_RE = re.compile(rf"^[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?$")
|
||||
_DEGENERATE_WORD_RE = re.compile(rf"[{_WORD_CHARS}]+")
|
||||
_TOKEN_RE = re.compile(rf"(?P<NUM>{_NUMERIC_PATTERN})|(?P<WORD>[{_WORD_CHARS}]+)")
|
||||
|
||||
|
||||
def _is_numeric_token(token: str) -> bool:
|
||||
if token.isdigit():
|
||||
return True
|
||||
return _NUMERIC_FULLMATCH_RE.fullmatch(token) is not None
|
||||
|
||||
|
||||
class _DegenerateStreamGuard:
|
||||
@@ -432,32 +443,50 @@ class _DegenerateStreamGuard:
|
||||
"Summer Summer ..."). This is not a useful response and can burn context,
|
||||
browser memory, and GPU time. Keep the guard conservative: only fire on long
|
||||
same-token runs or a very dominant repeated token in the recent window.
|
||||
Repetition made only of numbers is data (a zero or identity matrix, a
|
||||
padded buffer), so it only stops at a long all-numeric backstop.
|
||||
"""
|
||||
|
||||
def __init__(self, model: str):
|
||||
self.model = model or "model"
|
||||
self.last_token = ""
|
||||
self.same_run = 0
|
||||
self.numeric_run = 0
|
||||
self.recent_tokens: List[str] = []
|
||||
self.periodic_tokens: List[str] = []
|
||||
self.total_chars = 0
|
||||
self._buffer = ""
|
||||
|
||||
def check(self, text: str) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
self.total_chars += len(text)
|
||||
# Keep one-character tokens. Structured output often varies through
|
||||
# numeric selectors or coordinates; dropping those tokens can make
|
||||
# valid CSS/SVG rows look like an exact repeated phrase.
|
||||
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text)]
|
||||
self._buffer += text
|
||||
|
||||
m_delims = list(_DELIM_RE.finditer(self._buffer))
|
||||
if m_delims:
|
||||
split_idx = m_delims[-1].end()
|
||||
head = self._buffer[:split_idx]
|
||||
self._buffer = self._buffer[split_idx:]
|
||||
elif len(self._buffer) >= 256:
|
||||
head = self._buffer
|
||||
self._buffer = ""
|
||||
else:
|
||||
return None
|
||||
|
||||
tokens = []
|
||||
for m in _TOKEN_RE.finditer(head):
|
||||
tokens.append((m.group(0).lower(), m.lastgroup == "NUM"))
|
||||
if not tokens:
|
||||
return None
|
||||
for token in tokens:
|
||||
|
||||
for token, is_num in tokens:
|
||||
if token == self.last_token:
|
||||
self.same_run += 1
|
||||
else:
|
||||
self.last_token = token
|
||||
self.same_run = 1
|
||||
self.numeric_run = self.numeric_run + 1 if is_num else 0
|
||||
self.recent_tokens.append(token)
|
||||
self.periodic_tokens.append(token)
|
||||
if len(self.recent_tokens) > 96:
|
||||
@@ -466,14 +495,20 @@ class _DegenerateStreamGuard:
|
||||
self.periodic_tokens = self.periodic_tokens[-2048:]
|
||||
|
||||
reason = None
|
||||
if self.same_run >= 28 and self.total_chars >= 100:
|
||||
if self.same_run >= 28 and self.total_chars >= 100 and not _is_numeric_token(self.last_token):
|
||||
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
||||
elif len(self.recent_tokens) >= 72:
|
||||
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
||||
count = self.recent_tokens.count(top)
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not _is_numeric_token(top):
|
||||
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
||||
if not reason and len(self.recent_tokens) >= 48:
|
||||
if (
|
||||
not reason
|
||||
and len(self.recent_tokens) >= 48
|
||||
# A window that is mostly numbers is matrix/table data; its
|
||||
# periodicity is real. Mixed loops still hit the exact-block check.
|
||||
and sum(_is_numeric_token(t) for t in self.recent_tokens) * 2 < len(self.recent_tokens)
|
||||
):
|
||||
# Detect a periodic suffix instead of counting repeated n-grams.
|
||||
# Reused property names are normal in CSS/SVG/JSON; a collapsed
|
||||
# generation repeats nearly every token at a short fixed period.
|
||||
@@ -508,12 +543,15 @@ class _DegenerateStreamGuard:
|
||||
if history[previous:previous + anchor_size] != anchor:
|
||||
continue
|
||||
period = current_anchor - previous
|
||||
if period < 24 or len(history) < period * 2:
|
||||
if period < 24 or len(history) < period * 2 or self.numeric_run >= period:
|
||||
continue
|
||||
if history[-2 * period:-period] == history[-period:]:
|
||||
if history[-2 * period:-period] == history[-period:] and not all(_is_numeric_token(t) for t in history[-period:]):
|
||||
reason = f"repeated an exact {period}-token block twice"
|
||||
break
|
||||
|
||||
if not reason and self.numeric_run >= 2048:
|
||||
reason = f"emitted {self.numeric_run} numbers in a row"
|
||||
|
||||
if not reason:
|
||||
return None
|
||||
|
||||
@@ -1556,8 +1594,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.
|
||||
|
||||
@@ -10,6 +10,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import contextlib
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from src.database import McpServer, SessionLocal
|
||||
|
||||
@@ -383,12 +384,21 @@ class McpManager:
|
||||
|
||||
provider = build_provider(server_id, url, on_redirect=_on_redirect)
|
||||
stack = AsyncExitStack()
|
||||
try:
|
||||
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
||||
read_stream, write_stream, _get_session_id = transport
|
||||
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
||||
await session.initialize()
|
||||
|
||||
tools_result = await session.list_tools()
|
||||
except BaseException:
|
||||
# Close the half-open transport here, in the task that entered
|
||||
# it. Left for the garbage collector, its AnyIO cancel scope is
|
||||
# exited from the wrong task and the event loop keeps spinning
|
||||
# (#5518). Its own teardown error would only mask the real one.
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
raise
|
||||
tools = []
|
||||
for tool in tools_result.tools:
|
||||
tools.append({
|
||||
|
||||
@@ -193,7 +193,10 @@ class ModelDiscovery:
|
||||
data = r.json()
|
||||
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
|
||||
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
|
||||
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||
ids = [
|
||||
m["id"] for m in items
|
||||
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]
|
||||
]
|
||||
if ids:
|
||||
return {
|
||||
"host": host,
|
||||
|
||||
+4
-2
@@ -102,8 +102,10 @@ _PLAIN_FRONT_MATTER_RE = re.compile(
|
||||
# - **label:** value <!-- field=NAME-ENC type=text -->
|
||||
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
|
||||
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
|
||||
# Accept Unicode word characters for older saved markers, whose encoder left
|
||||
# non-ASCII letters/numbers literal. Newly rendered names are ASCII-safe.
|
||||
_FIELD_BULLET_RE = re.compile(
|
||||
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[A-Za-z0-9_.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
||||
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[\w.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
||||
)
|
||||
|
||||
|
||||
@@ -115,7 +117,7 @@ def _encode_name(name: str) -> str:
|
||||
"""
|
||||
out = []
|
||||
for ch in name or "":
|
||||
if ch.isalnum() or ch in ("_", ".", "-"):
|
||||
if (ch.isascii() and ch.isalnum()) or ch in ("_", ".", "-"):
|
||||
out.append(ch)
|
||||
else:
|
||||
for b in ch.encode("utf-8"):
|
||||
|
||||
+8
-51
@@ -12,16 +12,6 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Names that indicate a throwaway/test session
|
||||
_THROWAWAY_NAMES = {
|
||||
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
|
||||
"yo", "sup", "hola", "hii", "hiii", "heyo",
|
||||
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
|
||||
"new chat", "delete", "remove", "junk", "trash", "xxx",
|
||||
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
|
||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
||||
}
|
||||
_THROWAWAY_MAX_MESSAGES = 4
|
||||
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
|
||||
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
|
||||
|
||||
@@ -54,14 +44,15 @@ def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> boo
|
||||
|
||||
|
||||
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
|
||||
"""Run session cleanup + (optional) AI folder sort for the given owner.
|
||||
"""Remove empty/Incognito sessions and optionally organize saved chats.
|
||||
|
||||
Args:
|
||||
owner: user whose sessions to process
|
||||
skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
|
||||
skip_llm: when True, do only Phase 1 (delete empty/Incognito sessions);
|
||||
skip Phase 2 (AI folder assignment). Used by the built-in daily
|
||||
background sweep so it never burns LLM tokens.
|
||||
delete_throwaway: when False, only empty/incognito sessions are deleted.
|
||||
delete_throwaway: retained for caller compatibility. Ordinary sessions
|
||||
with saved messages are always preserved, regardless of this flag.
|
||||
|
||||
Returns a human-readable summary of what was done.
|
||||
"""
|
||||
@@ -71,7 +62,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# ── Phase 1: Delete empty/throwaway sessions ──
|
||||
# ── Phase 1: Delete only empty and explicitly Incognito sessions ──
|
||||
deleted_empty = 0
|
||||
deleted_throwaway = 0
|
||||
|
||||
@@ -93,47 +84,13 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
if is_session_recently_active(row, now=cleanup_now):
|
||||
continue
|
||||
|
||||
msg_count = db.query(DbMsg.id).filter(
|
||||
has_messages = db.query(DbMsg.id).filter(
|
||||
DbMsg.session_id == row.id
|
||||
).limit(_THROWAWAY_MAX_MESSAGES + 1).count()
|
||||
should_delete = False
|
||||
|
||||
if msg_count == 0:
|
||||
).first() is not None
|
||||
if not has_messages:
|
||||
if is_fresh:
|
||||
continue
|
||||
should_delete = True
|
||||
deleted_empty += 1
|
||||
elif delete_throwaway and msg_count <= _THROWAWAY_MAX_MESSAGES:
|
||||
name = (row.name or "").strip().lower()
|
||||
first_msg = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
||||
).order_by(DbMsg.timestamp).first()
|
||||
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
|
||||
assistant_count = db.query(DbMsg.id).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "assistant"
|
||||
).limit(1).count()
|
||||
|
||||
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
elif msg_count == 1 and assistant_count == 0:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
elif msg_count <= 4 and first_text and len(first_text.split()) <= 8 and len(first_text) <= 80:
|
||||
# Short trivial chats — e.g. "write hi to a friend" → "Hi!"
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
else:
|
||||
# Aggressive: total message text under 250 chars combined = trivial
|
||||
msg_rows = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id
|
||||
).all()
|
||||
total_chars = sum(len(m[0] or "") for m in msg_rows)
|
||||
if total_chars <= 250:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
|
||||
if should_delete:
|
||||
db.delete(row)
|
||||
|
||||
if deleted_empty or deleted_throwaway:
|
||||
|
||||
+16
-1
@@ -46,6 +46,7 @@ DEFAULT_SETTINGS = {
|
||||
"agent_email_confirm": True,
|
||||
"image_gen_enabled": False,
|
||||
"image_model": "",
|
||||
"image_edit_model": "",
|
||||
"image_quality": "medium",
|
||||
# Optional editor planner/caption endpoints. Empty uses configured chat
|
||||
# fallback without probing a developer-specific host.
|
||||
@@ -294,7 +295,7 @@ def is_setting_overridden(key: str) -> bool:
|
||||
# resolved by FastAPI deps; an empty/None owner falls through to the global.
|
||||
_PER_USER_KEYS = {
|
||||
"vision_model", "vision_enabled", "vision_model_fallbacks",
|
||||
"image_model", "image_gen_enabled", "image_quality",
|
||||
"image_model", "image_edit_model", "image_gen_enabled", "image_quality",
|
||||
# Default chat endpoint / model — without per-user resolution every new
|
||||
# account inherited whatever the most-recent admin picked, which then
|
||||
# got injected into the chat composer on first open.
|
||||
@@ -313,6 +314,20 @@ def get_user_setting(key: str, owner: str = "", default: Any = None) -> Any:
|
||||
Falls back gracefully if the prefs module can't be imported (cycle/early
|
||||
boot) — admin-global settings keep working.
|
||||
"""
|
||||
if key == "image_gen_enabled":
|
||||
global_val = get_setting("image_gen_enabled", default if default is not None else False)
|
||||
if not global_val:
|
||||
return False
|
||||
if owner:
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user
|
||||
prefs = _load_for_user(owner) or {}
|
||||
if "image_gen_enabled" in prefs and prefs["image_gen_enabled"] is not None:
|
||||
return bool(prefs["image_gen_enabled"])
|
||||
except Exception:
|
||||
pass
|
||||
return bool(global_val)
|
||||
|
||||
if owner and key in _PER_USER_KEYS:
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user
|
||||
|
||||
+22
-2
@@ -132,6 +132,23 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
|
||||
_shared_cache_pending.pop(key, None)
|
||||
|
||||
|
||||
def is_valid_cron(cron_expression: str) -> bool:
|
||||
"""True for a standard 5-field cron expression that croniter can parse.
|
||||
|
||||
croniter also accepts 6- and 7-field expressions with a seconds field, but
|
||||
the scheduler works at one-minute resolution and would run such a task
|
||||
every second.
|
||||
"""
|
||||
if len((cron_expression or "").split()) != 5:
|
||||
return False
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(cron_expression)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def compute_next_run(schedule: str, scheduled_time: str,
|
||||
scheduled_day: int = None,
|
||||
scheduled_date: datetime = None,
|
||||
@@ -175,6 +192,9 @@ def compute_next_run(schedule: str, scheduled_time: str,
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if schedule == "cron" and cron_expression:
|
||||
if not is_valid_cron(cron_expression):
|
||||
logger.warning(f"Invalid cron expression '{cron_expression}'")
|
||||
return None
|
||||
try:
|
||||
from croniter import croniter
|
||||
cron = croniter(cron_expression, now)
|
||||
@@ -306,7 +326,7 @@ def _digest_windows(now):
|
||||
|
||||
|
||||
def _checkin_calendar_events(db, owner, start, end):
|
||||
"""Calendar events in [start, end] for ONE owner, for the check-in digest.
|
||||
"""Calendar events in [start, end) for ONE owner, for the check-in digest.
|
||||
|
||||
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
|
||||
The digest query had no owner scope, so it pulled EVERY user's events into
|
||||
@@ -320,7 +340,7 @@ def _checkin_calendar_events(db, owner, start, end):
|
||||
.filter(
|
||||
_CC.owner == owner,
|
||||
_CE.dtstart >= start,
|
||||
_CE.dtstart <= end,
|
||||
_CE.dtstart < end,
|
||||
_CE.status != "cancelled",
|
||||
)
|
||||
.order_by(_CE.dtstart)
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"manage_tokens": "API token management: list, create, or delete API access tokens.",
|
||||
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
|
||||
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
|
||||
"manage_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.",
|
||||
|
||||
+4
-3
@@ -41,6 +41,7 @@ _REQUIRED_NATIVE_TOOL_ARGS = {
|
||||
"write_file": ("path",),
|
||||
"edit_file": ("path",),
|
||||
"apply_patch": ("patch_text", "patchText", "patch"),
|
||||
"generate_image": ("prompt",),
|
||||
"host_shell": ("command",),
|
||||
}
|
||||
|
||||
@@ -1292,14 +1293,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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -4567,7 +4592,9 @@ body.bg-pattern-ascii-fireflies {
|
||||
width:100%; background:var(--bg); color:var(--fg);
|
||||
border:1px solid var(--border); border-radius:6px;
|
||||
padding:8px; font-family:inherit; font-size:0.95rem;
|
||||
resize:vertical; outline:none;
|
||||
resize:both; outline:none;
|
||||
field-sizing:content;
|
||||
max-width:100%; box-sizing:border-box;
|
||||
min-height:80px;
|
||||
}
|
||||
.edit-textarea:focus { border-color:var(--red); }
|
||||
@@ -5116,7 +5143,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 +5174,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 +7258,39 @@ body.bg-pattern-ascii-fireflies {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Inline assistant markdown images (``) — rendered by
|
||||
static/js/markdown.js as a.clickable <a class="chat-image"> 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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+5149
-95
File diff suppressed because it is too large
Load Diff
@@ -2690,11 +2690,14 @@ body.theme-frosted .modal {
|
||||
max-height: 95dvh !important;
|
||||
}
|
||||
|
||||
/* Notes and Documents must respect the same viewport and safe-area
|
||||
top as other mobile full-height sheets. Keep these authoritative rules
|
||||
consistent with 05-documents.css and 14-email.css. */
|
||||
body.doc-view .doc-editor-pane,
|
||||
body.notes-view .notes-pane {
|
||||
inset: auto 0 0 !important;
|
||||
height: 95dvh !important;
|
||||
max-height: 95dvh !important;
|
||||
inset: var(--sheet-top) 0 0 !important;
|
||||
height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -978,3 +978,343 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
color: var(--accent, var(--red));
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
/* Colour chips: every pickable colour input renders as a circle, regardless of
|
||||
the container it sits in (options bar .ge-text-field, Character panel,
|
||||
gradient stops, export matte, etc.). Forced with !important so parent
|
||||
input[type="color"] rules can't square them off. */
|
||||
input.ge-color-picker,
|
||||
input.ge-color-picker.cp-swatch-input,
|
||||
.ge-text-field input.ge-color-picker,
|
||||
.ge-swatch input.ge-color-picker,
|
||||
.ge-filter-row input.ge-color-picker,
|
||||
.ge-gradient-color-row input.ge-color-picker {
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
input.ge-color-picker::-webkit-color-swatch {
|
||||
border: 0 !important;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
input.ge-color-picker::-moz-color-swatch {
|
||||
border: 0 !important;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Editor theme alignment: map the editor's controls onto the app's design
|
||||
tokens (static/css/00-tokens.css) so the editor matches the rest of the
|
||||
product. No new colours — only the shared CSS variables.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
/* Checkboxes + radios → the app's circular dot (see 17-research.css). */
|
||||
body.ge-editor-open input[type="checkbox"],
|
||||
body.ge-editor-open input[type="radio"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
vertical-align: middle;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
body.ge-editor-open input[type="checkbox"]:checked,
|
||||
body.ge-editor-open input[type="radio"]:checked {
|
||||
background: var(--fg);
|
||||
border-color: var(--fg);
|
||||
}
|
||||
body.ge-editor-open input[type="checkbox"]:focus-visible,
|
||||
body.ge-editor-open input[type="radio"]:focus-visible {
|
||||
outline: 2px solid var(--red);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Selects → the app's select tokens. */
|
||||
body.ge-editor-open select {
|
||||
background: var(--select-bg, var(--bg));
|
||||
color: var(--select-fg, var(--fg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
body.ge-editor-open select option {
|
||||
background: var(--select-option-bg, var(--panel));
|
||||
color: var(--select-option-fg, var(--fg));
|
||||
}
|
||||
body.ge-editor-open select option:checked,
|
||||
body.ge-editor-open select option:hover {
|
||||
background: var(--select-option-active-bg, var(--panel));
|
||||
}
|
||||
body.ge-editor-open select:focus-visible {
|
||||
outline: 1px solid var(--red);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Text / number inputs → token background, border and focus ring. */
|
||||
body.ge-editor-open input[type="text"],
|
||||
body.ge-editor-open input[type="number"],
|
||||
body.ge-editor-open input[type="search"],
|
||||
body.ge-editor-open textarea {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
body.ge-editor-open input[type="text"]:focus-visible,
|
||||
body.ge-editor-open input[type="number"]:focus-visible,
|
||||
body.ge-editor-open input[type="search"]:focus-visible,
|
||||
body.ge-editor-open textarea:focus-visible {
|
||||
outline: 1px solid var(--red);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Range sliders → the app's accent. */
|
||||
body.ge-editor-open input[type="range"] {
|
||||
accent-color: var(--accent, var(--red));
|
||||
}
|
||||
|
||||
/* Scrollbars → the app's 8px red-thumb recipe (00-tokens.css). */
|
||||
body.ge-editor-open * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--red) var(--panel);
|
||||
}
|
||||
body.ge-editor-open *::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
body.ge-editor-open *::-webkit-scrollbar-track {
|
||||
background: var(--panel);
|
||||
}
|
||||
body.ge-editor-open *::-webkit-scrollbar-thumb {
|
||||
background-color: var(--red);
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--panel);
|
||||
}
|
||||
body.ge-editor-open *::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(in srgb, var(--red) 80%, white);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Editor theme alignment (part 2): buttons, segmented toggles, tabs, list
|
||||
rows, panel headers, badges, focus rings, number fields and colour-picker
|
||||
chrome mapped onto the app's shared design tokens. Existing tokens only —
|
||||
no new colours, and no geometry beyond colour / border / radius / focus.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
/* The editor references two secondary / muted variables that the app token
|
||||
layer never defines; map them onto the real tokens so every
|
||||
var(--fg-muted) / var(--bg-secondary) editor rule resolves. */
|
||||
body.ge-editor-open {
|
||||
--fg-muted: var(--color-muted);
|
||||
--bg-secondary: var(--panel);
|
||||
}
|
||||
|
||||
/* Buttons → the app's `.cal-btn` recipe (5px radius; the primary button
|
||||
uses the accent fill with a --bg foreground instead of a hard-coded #fff). */
|
||||
body.ge-editor-open .ge-btn {
|
||||
border-radius: 5px;
|
||||
}
|
||||
body.ge-editor-open .ge-btn-primary {
|
||||
background: var(--accent, var(--red));
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--bg);
|
||||
}
|
||||
body.ge-editor-open .ge-btn-primary:hover {
|
||||
background: var(--accent, var(--red));
|
||||
opacity: 0.9;
|
||||
}
|
||||
body.ge-editor-open .ge-win-close:hover {
|
||||
background: var(--accent, var(--red));
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* Segmented / option toggles, command palette and channel rows → the app's
|
||||
accent token (--red) instead of the editor's blue #4c8bf5 fallback. */
|
||||
body.ge-editor-open .ge-opt-mode.active,
|
||||
body.ge-editor-open .ge-crop-preset.active,
|
||||
body.ge-editor-open .ge-command-item.active,
|
||||
body.ge-editor-open .ge-command-item:hover {
|
||||
background: var(--accent, var(--red));
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--bg);
|
||||
}
|
||||
body.ge-editor-open .ge-channel-row.active,
|
||||
body.ge-editor-open .ge-dock-tabs-menu-item:hover,
|
||||
body.ge-editor-open .ge-filter-menu .dropdown-item-compact:focus-visible,
|
||||
body.ge-editor-open .ge-view-menu .dropdown-item-compact:focus-visible,
|
||||
body.ge-editor-open .ge-window-menu .dropdown-item-compact:focus-visible,
|
||||
body.ge-editor-open .ge-help-menu .dropdown-item-compact:focus-visible,
|
||||
body.ge-editor-open .ge-3d-menu .dropdown-item-compact:focus-visible,
|
||||
body.ge-editor-open .ge-plugins-menu .dropdown-item-compact:focus-visible {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 22%, transparent);
|
||||
}
|
||||
body.ge-editor-open .ge-sam-brush-add.active,
|
||||
body.ge-editor-open .ge-sam-brush-subtract.active,
|
||||
body.ge-editor-open .ge-actions-controls .ge-btn.active {
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--accent, var(--red));
|
||||
}
|
||||
|
||||
/* Tabs → the app tab convention: the active tab's underline is the accent. */
|
||||
body.ge-editor-open .ge-dock-tab.active {
|
||||
border-bottom-color: var(--accent, var(--red));
|
||||
}
|
||||
|
||||
/* Colour-picker chrome → the token accent instead of the blue fallback. */
|
||||
body.ge-editor-open .ge-swatch.active {
|
||||
outline-color: var(--accent, var(--red));
|
||||
}
|
||||
|
||||
/* Badges / status pills → the app's --bg foreground on an accent fill. */
|
||||
body.ge-editor-open .ge-tool-clear {
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* Panel headers → the app's muted section-title token. */
|
||||
body.ge-editor-open .ge-panel-title,
|
||||
body.ge-editor-open .ge-info-label {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Focus rings → the app's 1px --red ring (matching the earlier input/select
|
||||
block) on the controls that previously fell back to the UA outline. */
|
||||
body.ge-editor-open .ge-btn:focus-visible,
|
||||
body.ge-editor-open .ge-tool-btn:focus-visible,
|
||||
body.ge-editor-open .ge-doc-tab:focus-visible,
|
||||
body.ge-editor-open .ge-history-row:focus-visible,
|
||||
body.ge-editor-open .ge-swatch:focus-visible,
|
||||
body.ge-editor-open .ge-opt-mode:focus-visible,
|
||||
body.ge-editor-open .ge-stepper button:focus-visible {
|
||||
outline: 1px solid var(--red);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Editor theme alignment (part 3): the setup / preflight checklist and the
|
||||
remaining checkbox list-row / badge chrome mapped onto the app's shared
|
||||
design tokens. Existing tokens only — no new colours, and no geometry beyond
|
||||
colour / border / radius / focus. Scoped to body.ge-editor-open.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
/* Checklist card + rows → the app's panel / border / muted tokens. */
|
||||
body.ge-editor-open .ge-preflight-card {
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-head p,
|
||||
body.ge-editor-open .ge-preflight-hint,
|
||||
body.ge-editor-open .ge-preflight-detail,
|
||||
body.ge-editor-open .ge-preflight-remember,
|
||||
body.ge-editor-open .ge-preflight-close {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-close:hover {
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
color: var(--fg);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-row {
|
||||
background: color-mix(in srgb, var(--panel) 92%, var(--fg) 4%);
|
||||
border-color: var(--border);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-row.is-done {
|
||||
border-color: color-mix(in srgb, var(--green) 45%, var(--border));
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-row.is-todo {
|
||||
border-color: var(--red);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-row.is-optional {
|
||||
border-color: color-mix(in srgb, var(--accent-warm) 45%, var(--border));
|
||||
}
|
||||
|
||||
/* Row badges → the semantic tokens (done = green, todo = red, optional = warm). */
|
||||
body.ge-editor-open .ge-preflight-badge {
|
||||
background: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
color: var(--fg);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-row.is-done .ge-preflight-badge {
|
||||
background: color-mix(in srgb, var(--green) 20%, transparent);
|
||||
color: var(--green);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-row.is-todo .ge-preflight-badge {
|
||||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||
color: var(--red);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-row.is-optional .ge-preflight-badge {
|
||||
background: color-mix(in srgb, var(--accent-warm) 16%, transparent);
|
||||
color: var(--accent-warm);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-opt {
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Checklist actions / footer → token buttons and borders. */
|
||||
body.ge-editor-open .ge-preflight-act {
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
border-color: var(--border);
|
||||
color: var(--fg);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-act:hover {
|
||||
background: color-mix(in srgb, var(--fg) 16%, transparent);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-foot {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-summary {
|
||||
color: var(--green);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-start {
|
||||
background: var(--accent, var(--red));
|
||||
color: var(--bg);
|
||||
}
|
||||
body.ge-editor-open .ge-preflight-start:hover {
|
||||
background: var(--accent, var(--red));
|
||||
opacity: 0.9;
|
||||
}
|
||||
body.ge-editor-open .ge-setup-btn {
|
||||
border-color: var(--border);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
body.ge-editor-open .ge-setup-btn:hover {
|
||||
background: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* The options-bar checkbox overrode the shared dot with a square + a drawn
|
||||
::after tick; force it back to the app's 14px circular dot, filled with the
|
||||
highlight (accent) colour when checked (part 1). */
|
||||
body.ge-editor-open .ge-opt input[type="checkbox"],
|
||||
body.ge-editor-open .ge-opt input[type="radio"] {
|
||||
border-radius: 50%;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
body.ge-editor-open .ge-opt input[type="checkbox"]:checked,
|
||||
body.ge-editor-open .ge-opt input[type="radio"]:checked {
|
||||
background: var(--accent, var(--red));
|
||||
border-color: var(--accent, var(--red));
|
||||
}
|
||||
body.ge-editor-open .ge-opt input[type="checkbox"]:checked::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Remaining list-row / thumbnail chrome → panel + border + muted tokens. */
|
||||
body.ge-editor-open .ge-history-row-dot {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
body.ge-editor-open .ge-layer-inline-thumb {
|
||||
background: var(--panel);
|
||||
border-color: var(--border);
|
||||
}
|
||||
body.ge-editor-open .ge-channel-row,
|
||||
body.ge-editor-open .ge-channel-name {
|
||||
color: var(--fg);
|
||||
}
|
||||
body.ge-editor-open .ge-channel-eye {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
-7
@@ -16,13 +16,6 @@
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
window._odysseusLoadTime = Date.now();
|
||||
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
|
||||
(function(){
|
||||
var ua = navigator.userAgent || '';
|
||||
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
|
||||
var vp = document.querySelector('meta[name="viewport"]');
|
||||
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
|
||||
})();
|
||||
(function(){
|
||||
try {
|
||||
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
||||
@@ -664,6 +657,7 @@
|
||||
<div class="theme-fd-group">
|
||||
<label class="theme-fd-label">Font</label>
|
||||
<select id="theme-font-select" class="theme-fd-select" aria-label="Font">
|
||||
<option value="gohu">GohuFont</option>
|
||||
<option value="mono">Monospace</option>
|
||||
<option value="sans">Sans-serif</option>
|
||||
<option value="serif">Serif</option>
|
||||
@@ -1683,6 +1677,10 @@
|
||||
<label class="settings-label">Model</label>
|
||||
<select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label class="settings-label" for="set-imgEditModelSelect">Image edit model</label>
|
||||
<select id="set-imgEditModelSelect" class="settings-select"><option value="">Same as generation</option></select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label class="settings-label">Quality</label>
|
||||
<select id="set-imgQualitySelect" class="settings-select">
|
||||
|
||||
+61
-1
@@ -18,7 +18,13 @@
|
||||
'use strict';
|
||||
|
||||
// Click-as-button rows we want reachable by keyboard.
|
||||
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
|
||||
var ROW_SELECTOR = [
|
||||
'#sidebar .list-item',
|
||||
'#user-bar-profile',
|
||||
'.sidebar-brand',
|
||||
'.export-dropdown-item',
|
||||
'.dropdown-item'
|
||||
].join(',');
|
||||
|
||||
// Native interactive descendants. If a row contains one of these we must
|
||||
// NOT give the row role="button" — a button inside a button is invalid
|
||||
@@ -55,6 +61,31 @@
|
||||
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
|
||||
}
|
||||
|
||||
// Settings form labels are written as <label class="settings-label">Text</label>
|
||||
// with the control as a sibling/descendant, but are not associated via for/id,
|
||||
// so screen readers cannot name the inputs. Associate each label with its
|
||||
// control when the mapping is unambiguous (a single form control per row).
|
||||
var settingsLabelSeq = 0;
|
||||
function associateSettingsLabels(root) {
|
||||
(root || document).querySelectorAll('.settings-label').forEach(function (label) {
|
||||
if (label.hasAttribute('for')) return;
|
||||
var control = null;
|
||||
var sib = label.nextElementSibling;
|
||||
if (sib && /^(INPUT|SELECT|TEXTAREA)$/.test(sib.tagName)) {
|
||||
control = sib;
|
||||
} else {
|
||||
var row = label.closest('.settings-row') || label.parentElement;
|
||||
if (row) {
|
||||
var controls = row.querySelectorAll('input,select,textarea');
|
||||
if (controls.length === 1) control = controls[0];
|
||||
}
|
||||
}
|
||||
if (!control || control.closest('label')) return;
|
||||
if (!control.id) control.id = 'a11y-settings-' + (++settingsLabelSeq);
|
||||
if (!control.getAttribute('aria-label')) label.setAttribute('for', control.id);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Modal dialogs -----------------------------------------------------
|
||||
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
|
||||
// them as ARIA dialogs lets screen readers announce them as dialogs and
|
||||
@@ -118,9 +149,38 @@
|
||||
el.click();
|
||||
});
|
||||
|
||||
// Disclosure buttons: keep aria-expanded in sync with their controlled
|
||||
// menu/dropdown, regardless of which module toggles its visibility.
|
||||
var DISCLOSURE_MAP = [
|
||||
{ button: 'session-sort-btn', menu: 'session-sort-dropdown' },
|
||||
{ button: 'export-dl-btn', menu: 'export-dropdown-menu' },
|
||||
{ button: 'overflow-plus-btn', menu: 'overflow-menu' }
|
||||
];
|
||||
|
||||
function syncDisclosures() {
|
||||
DISCLOSURE_MAP.forEach(function (d) {
|
||||
var btn = document.getElementById(d.button);
|
||||
if (!btn) return;
|
||||
var menu = document.getElementById(d.menu);
|
||||
var open = false;
|
||||
if (menu) {
|
||||
var cs = window.getComputedStyle(menu);
|
||||
open = cs.display !== 'none' && menu.offsetWidth > 0;
|
||||
}
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
enhanceAll(document);
|
||||
enhanceModals(document);
|
||||
associateSettingsLabels(document);
|
||||
|
||||
// Any click (the dropdown toggles, outside-click closes, etc.) may change
|
||||
// a disclosure's open state; re-sync on the next tick.
|
||||
document.addEventListener('click', function () {
|
||||
if (window.setTimeout) setTimeout(syncDisclosures, 0);
|
||||
});
|
||||
|
||||
// Sidebar content is re-rendered as the user navigates (session lists,
|
||||
// tool sub-rows, etc.). Watch for new rows and enhance them too.
|
||||
|
||||
+7
-5
@@ -763,7 +763,7 @@ function renderFeatherlessPanel(panel, ep, row) {
|
||||
const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b));
|
||||
enabledListEl.innerHTML = sortedIds.map(id => {
|
||||
const displayName = id.split('/').pop() || id;
|
||||
const mode = ['none', 'compact', 'full'].includes(String(toolModes[id] || '').toLowerCase())
|
||||
const mode = ['none', 'compact', 'full', 'spec'].includes(String(toolModes[id] || '').toLowerCase())
|
||||
? String(toolModes[id]).toLowerCase()
|
||||
: '';
|
||||
return `<div title="${esc(id)}" data-ep-model-row data-model-id="${esc(id)}" class="adm-model-row">
|
||||
@@ -777,7 +777,8 @@ function renderFeatherlessPanel(panel, ep, row) {
|
||||
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(id)}" data-original-tool-mode="${esc(toolModes[id] || '')}">
|
||||
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
|
||||
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
|
||||
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
|
||||
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -1356,7 +1357,7 @@ async function loadEndpoints() {
|
||||
<a href="#" data-ep-select-none="${epId}">None</a>
|
||||
</span>
|
||||
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + 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 `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
|
||||
@@ -1370,7 +1371,8 @@ async function loadEndpoints() {
|
||||
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Auto uses Odysseus compact for Odysseus/Ajax names and Regular tools for every other model">
|
||||
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
|
||||
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
|
||||
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
|
||||
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -771,7 +771,13 @@ function _getModal() {
|
||||
<button class="cal-side-nav cal-side-nav-next" id="cal-side-next" title="Next" aria-label="Next">›</button>
|
||||
</div>`;
|
||||
document.body.appendChild(_modal);
|
||||
_modal.querySelector('#cal-close').addEventListener('click', closeCalendar);
|
||||
_modal.querySelector('#cal-close').addEventListener('click', () => {
|
||||
if (_modal.querySelector('.cal-form')) {
|
||||
_render();
|
||||
return;
|
||||
}
|
||||
closeCalendar();
|
||||
});
|
||||
_modal.querySelector('#cal-side-prev')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
function streamError(payload) {
|
||||
const detail = payload?.data ?? payload;
|
||||
const candidates = [detail?.message, detail?.text, detail?.error?.message,
|
||||
detail?.error, payload?.message, payload?.text, payload?.error?.message,
|
||||
payload?.error, typeof payload === 'string' ? payload : null];
|
||||
const message = candidates.find(value => typeof value === 'string' && value.trim());
|
||||
return new Error(message || 'Model request failed.');
|
||||
}
|
||||
|
||||
/** Decode complete SSE frames, requiring the backend's terminal DONE event. */
|
||||
export async function consumeCompareEvents(response, onEvent, onActivity) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let line = '', event = 'message', data = [], skipLF = false;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (!done) onActivity?.();
|
||||
let chunk = decoder.decode(value, { stream: !done });
|
||||
if (skipLF && chunk.length) {
|
||||
if (chunk.startsWith('\n')) chunk = chunk.slice(1);
|
||||
skipLF = false;
|
||||
}
|
||||
const separators = /\r\n|\r|\n/g;
|
||||
let start = 0, separator;
|
||||
while ((separator = separators.exec(chunk))) {
|
||||
line += chunk.slice(start, separator.index);
|
||||
start = separator.index + separator[0].length;
|
||||
skipLF = separator[0] === '\r' && start === chunk.length;
|
||||
if (!line) {
|
||||
if (data.length || event === 'error') {
|
||||
const raw = data.join('\n');
|
||||
if (event !== 'error' && raw === '[DONE]') return;
|
||||
let payload;
|
||||
try { payload = JSON.parse(raw); }
|
||||
catch (_) {
|
||||
if (event === 'error') throw streamError(raw);
|
||||
throw new Error('Invalid model stream data.');
|
||||
}
|
||||
if (event === 'error' || payload?.type === 'error') throw streamError(payload);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('Invalid model stream data.');
|
||||
}
|
||||
await onEvent(payload);
|
||||
}
|
||||
event = 'message';
|
||||
data = [];
|
||||
} else if (!line.startsWith(':')) {
|
||||
const colon = line.indexOf(':');
|
||||
const field = colon < 0 ? line : line.slice(0, colon);
|
||||
let value = colon < 0 ? '' : line.slice(colon + 1);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
if (field === 'data') data.push(value);
|
||||
else if (field === 'event') event = value || 'message';
|
||||
}
|
||||
line = '';
|
||||
}
|
||||
line += chunk.slice(start);
|
||||
if (done) throw new Error('Stream ended before completion.');
|
||||
}
|
||||
} finally {
|
||||
try { await reader.cancel(); } catch (_) {}
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
+12
-22
@@ -1,5 +1,6 @@
|
||||
// compare/stream.js — SSE streaming to panes
|
||||
import state from './state.js';
|
||||
import { consumeCompareEvents } from './sse.js';
|
||||
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
|
||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1';
|
||||
import markdownModule from '../markdown.js';
|
||||
@@ -472,25 +473,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
_resetIdleTimeout(); // any chunk = stream is alive
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
await consumeCompareEvents(response,
|
||||
(json) => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.type === 'metrics') {
|
||||
metrics = json.data;
|
||||
|
||||
@@ -672,7 +657,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
} else if (json.delta) {
|
||||
// Skip text deltas if we already rendered an image
|
||||
if (aiMsgEl._imageData) continue;
|
||||
if (aiMsgEl._imageData) return;
|
||||
// Capture TTFT on very first text delta
|
||||
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
|
||||
// On first delta, destroy spinner and prepare text area
|
||||
@@ -701,8 +686,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
_scheduleLiveRender(target);
|
||||
}
|
||||
} catch (e) { console.warn('Compare stream render error:', e); }
|
||||
}
|
||||
}
|
||||
}, _resetIdleTimeout);
|
||||
|
||||
streamOk = true;
|
||||
// Destroy any remaining spinner
|
||||
@@ -900,9 +884,15 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
} else {
|
||||
console.error('Compare stream error:', error);
|
||||
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(error.message) + '</span>';
|
||||
awaitingChoice = false;
|
||||
const message = state._blindMode ? 'Model request failed. Try again.' : error.message;
|
||||
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(message) + '</span>';
|
||||
}
|
||||
} finally {
|
||||
if (aiMsgEl._spinner) {
|
||||
aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
_timerDone = true;
|
||||
cancelAnimationFrame(_rafId);
|
||||
|
||||
@@ -1195,11 +1195,12 @@ async function _fetchDependencies() {
|
||||
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
|
||||
// append a caret button that toggles a per-row recipe panel below.
|
||||
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
|
||||
// Standalone recipe-caret button removed — the "Pick install
|
||||
// command" action lives inside the Installed ▾ dropdown menu
|
||||
// (see _showDepMenu) so each row only has ONE caret to click.
|
||||
// Kept the variable so downstream concat code stays the same.
|
||||
const recipeCaret = '';
|
||||
// The caret is the only way to open the recipe panel (the Installed ▾
|
||||
// menu has no recipe item), and the HW Fit / serve-card deep links
|
||||
// open it by clicking this caret (#6747).
|
||||
const recipeCaret = hasRecipe
|
||||
? `<button class="cookbook-dep-tag cookbook-dep-recipe-caret" data-dep-recipe-toggle="${esc(pkg.name)}" title="Pick a model to see the exact install commands" aria-expanded="false" style="background:none;border:1px solid var(--border);padding:2px 6px;display:inline-flex;align-items:center;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg></button>`
|
||||
: '';
|
||||
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
|
||||
// When llama_cpp (or any future engine) reports build_deps_missing
|
||||
// from its system_prereqs probe, surface a one-tap install button
|
||||
|
||||
@@ -1498,7 +1498,8 @@ function _rerenderCachedModels() {
|
||||
const _ggufOptions = _ggufChoices.map(f =>
|
||||
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
|
||||
).join('');
|
||||
const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
|
||||
const _minimaxM3Snapshot = '~/.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 : '';
|
||||
|
||||
+44
-7
@@ -1996,6 +1996,17 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// Active drop mode for the PDF toolbar — toolbar buttons set this; the
|
||||
// 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
|
||||
@@ -5197,7 +5232,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
|
||||
async function _handleAttachUpload(e) {
|
||||
const files = e.target.files;
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = ''; // reset for next upload
|
||||
await _uploadComposeFiles(files);
|
||||
}
|
||||
@@ -5928,7 +5963,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
|
||||
async function _handleMarkdownImageUpload(e) {
|
||||
const files = e.target.files;
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = '';
|
||||
await _uploadMarkdownImages(files);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
+33
-1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(); } };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { imageResultGeometry } from './image-result-geometry.js';
|
||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||
|
||||
export function wireInpaintButtons({
|
||||
@@ -184,17 +185,30 @@ export function wireInpaintButtons({
|
||||
if (!state.editorOpen) return; // user closed mid-decode
|
||||
try {
|
||||
saveState('Inpaint result');
|
||||
// OpenAI returns at one of its allowed sizes (1024²,
|
||||
// 1024×1536, 1536×1024) which often differs from our
|
||||
// canvas. Scale to canvas size with smoothing so the
|
||||
// inpaint blends in regardless of source dims.
|
||||
// Whole-image edits keep the provider's native frame; only masked
|
||||
// edits need registration with the source document's coordinates.
|
||||
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
||||
const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
|
||||
const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
const fullFrame = data.full_frame === true;
|
||||
const geometry = imageResultGeometry(resultImg.naturalWidth, resultImg.naturalHeight, state.imgWidth, state.imgHeight);
|
||||
const resultLayer = createLayer(layerName,
|
||||
fullFrame ? geometry.width : state.imgWidth,
|
||||
fullFrame ? geometry.height : state.imgHeight);
|
||||
resultLayer.ctx.imageSmoothingEnabled = true;
|
||||
resultLayer.ctx.imageSmoothingQuality = 'high';
|
||||
resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight);
|
||||
if (fullFrame) {
|
||||
resultLayer.ctx.drawImage(resultImg, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
|
||||
state.layerOffsets.set(resultLayer.id, {
|
||||
x: geometry.offsetX,
|
||||
y: geometry.offsetY,
|
||||
});
|
||||
} else {
|
||||
const scale = Math.min(state.imgWidth / resultImg.naturalWidth, state.imgHeight / resultImg.naturalHeight);
|
||||
const width = resultImg.naturalWidth * scale, height = resultImg.naturalHeight * scale;
|
||||
resultLayer.ctx.drawImage(resultImg, (state.imgWidth - width) / 2, (state.imgHeight - height) / 2, width, height);
|
||||
}
|
||||
// Snapshot the AI result + hard mask used for this run.
|
||||
if (!fullFrame) {
|
||||
const aiSnap = document.createElement('canvas');
|
||||
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
|
||||
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
|
||||
@@ -205,6 +219,7 @@ export function wireInpaintButtons({
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||
applyInpaintFeather(resultLayer, 0, 0);
|
||||
}
|
||||
state.layers.push(resultLayer);
|
||||
state.activeLayerId = resultLayer.id;
|
||||
state.lastInpaintLayerId = resultLayer.id;
|
||||
@@ -220,6 +235,10 @@ export function wireInpaintButtons({
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
if (fullFrame) {
|
||||
if (uiModule) uiModule.showToast('Image edit complete', 4500);
|
||||
return;
|
||||
}
|
||||
// Reveal post-generation Feather + Edge Stroke sliders.
|
||||
// Cap Edge Stroke at ±padPx so the slider can't ask for
|
||||
// more AI buffer than we generated.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user