mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 00:02:21 +01:00
Compare commits
54
Commits
@@ -178,6 +178,14 @@ app.add_middleware(SecurityHeadersMiddleware)
|
|||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware as _BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware as _BaseHTTPMiddleware
|
||||||
from starlette.responses import JSONResponse as _JSONResponse
|
from starlette.responses import JSONResponse as _JSONResponse
|
||||||
|
import anyio as _anyio
|
||||||
|
from starlette.requests import ClientDisconnect as _ClientDisconnect
|
||||||
|
|
||||||
|
# SSE endpoints (e.g. /api/editor/bridge/events) end/abort mid-stream when the
|
||||||
|
# browser disconnects; Starlette's BaseHTTPMiddleware surfaces that as
|
||||||
|
# anyio.EndOfStream (and sometimes ClientDisconnect). Those are client-side
|
||||||
|
# events, not server errors, so we map them to a 499 instead of a 500 traceback.
|
||||||
|
_STREAM_CLIENT_EXC = (_anyio.EndOfStream, _ClientDisconnect)
|
||||||
|
|
||||||
REQUEST_HARD_TIMEOUT = float(os.getenv("REQUEST_HARD_TIMEOUT", "45"))
|
REQUEST_HARD_TIMEOUT = float(os.getenv("REQUEST_HARD_TIMEOUT", "45"))
|
||||||
_TIMEOUT_EXEMPT_PREFIXES = (
|
_TIMEOUT_EXEMPT_PREFIXES = (
|
||||||
@@ -190,6 +198,7 @@ _TIMEOUT_EXEMPT_PREFIXES = (
|
|||||||
"/api/cookbook/setup", # remote pacman/apt installs
|
"/api/cookbook/setup", # remote pacman/apt installs
|
||||||
"/api/upload", # large files
|
"/api/upload", # large files
|
||||||
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
||||||
|
"/api/editor", # editor agent/bridge: generative steps can take 30-110s (SAM + local diffusion)
|
||||||
"/api/memory/audit", # retains own 120s LLM inactivity timeout
|
"/api/memory/audit", # retains own 120s LLM inactivity timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -225,15 +234,43 @@ class _InteractiveActivityMiddleware(_BaseHTTPMiddleware):
|
|||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
# Heavy image ops that can run 30-150 s; a deploy restart must not cut these off.
|
||||||
|
_HEAVY_IMAGE_OP_PREFIXES = (
|
||||||
|
"/api/image/inpaint",
|
||||||
|
"/api/image/replace-background",
|
||||||
|
"/api/image/outpaint",
|
||||||
|
"/api/image/extend",
|
||||||
|
"/api/image/remove-bg",
|
||||||
|
"/api/image/mask",
|
||||||
|
"/api/image/upscale-local",
|
||||||
|
"/api/image/denoise",
|
||||||
|
"/api/image/enhance-face",
|
||||||
|
"/api/gallery/ai-upscale",
|
||||||
|
"/api/gallery/style-transfer",
|
||||||
|
"/api/editor/agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _SlowRequestLogMiddleware(_BaseHTTPMiddleware):
|
class _SlowRequestLogMiddleware(_BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request, call_next):
|
async def dispatch(self, request, call_next):
|
||||||
|
from src import activity
|
||||||
|
path = request.url.path or ""
|
||||||
|
heavy = any(path.startswith(p) for p in _HEAVY_IMAGE_OP_PREFIXES)
|
||||||
|
if heavy:
|
||||||
|
activity.begin_image_op(path)
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
status = 500
|
status = 500
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
status = getattr(response, "status_code", 0) or 0
|
status = getattr(response, "status_code", 0) or 0
|
||||||
return response
|
return response
|
||||||
|
except _STREAM_CLIENT_EXC:
|
||||||
|
# Client disconnected / SSE stream ended — not a server error.
|
||||||
|
status = 499
|
||||||
|
return _JSONResponse({"detail": "client disconnected"}, status_code=499)
|
||||||
finally:
|
finally:
|
||||||
|
if heavy:
|
||||||
|
activity.end_image_op(path)
|
||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
try:
|
try:
|
||||||
threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75")
|
threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75")
|
||||||
@@ -822,6 +859,10 @@ app.include_router(setup_gallery_routes())
|
|||||||
from routes.editor_draft_routes import setup_editor_draft_routes
|
from routes.editor_draft_routes import setup_editor_draft_routes
|
||||||
app.include_router(setup_editor_draft_routes())
|
app.include_router(setup_editor_draft_routes())
|
||||||
|
|
||||||
|
# Natural-language editor planning (planner wired to the configured chat model)
|
||||||
|
from routes.editor_agent_routes import setup_editor_agent_routes
|
||||||
|
app.include_router(setup_editor_agent_routes())
|
||||||
|
|
||||||
# Scheduled tasks + event bus
|
# Scheduled tasks + event bus
|
||||||
from src.task_scheduler import TaskScheduler
|
from src.task_scheduler import TaskScheduler
|
||||||
task_scheduler = TaskScheduler(session_manager)
|
task_scheduler = TaskScheduler(session_manager)
|
||||||
@@ -1000,6 +1041,16 @@ async def get_version():
|
|||||||
"source_commit": APP_SOURCE_COMMIT,
|
"source_commit": APP_SOURCE_COMMIT,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/busy")
|
||||||
|
async def get_busy():
|
||||||
|
"""Report in-flight heavy work so a deploy can wait instead of dropping it."""
|
||||||
|
from src import activity
|
||||||
|
return {
|
||||||
|
"busy_image_ops": activity.heavy_image_ops_active(),
|
||||||
|
**activity.activity(),
|
||||||
|
}
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health_check() -> Dict[str, str]:
|
async def health_check() -> Dict[str, str]:
|
||||||
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||||
@@ -1438,4 +1489,35 @@ if __name__ == "__main__":
|
|||||||
bind_host = os.getenv("APP_BIND", "127.0.0.1")
|
bind_host = os.getenv("APP_BIND", "127.0.0.1")
|
||||||
bind_port = int(os.getenv("APP_PORT", "7011"))
|
bind_port = int(os.getenv("APP_PORT", "7011"))
|
||||||
|
|
||||||
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|
# Give in-flight requests time to finish on SIGTERM instead of being cut
|
||||||
|
# off mid-flight. Diffusion edits run 60-120 s; outpaint/extend can run up
|
||||||
|
# to ~330 s (provider cap 900 s), so default the graceful window to 900 s.
|
||||||
|
# systemd's TimeoutStopSec must exceed this (see the graceful-stop drop-in).
|
||||||
|
#
|
||||||
|
# We hook uvicorn's signal handler so a SIGTERM also flips src.shutdown:
|
||||||
|
# SSE / streaming generators check it and end promptly, so an open browser
|
||||||
|
# stream cannot stall a restart for the whole window. The long window stays
|
||||||
|
# a safety net for genuine in-flight image ops.
|
||||||
|
from src import shutdown as _shutdown
|
||||||
|
|
||||||
|
config = uvicorn.Config(
|
||||||
|
app,
|
||||||
|
host=bind_host,
|
||||||
|
port=bind_port,
|
||||||
|
log_level="info",
|
||||||
|
timeout_graceful_shutdown=int(os.getenv("ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900") or 900),
|
||||||
|
)
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
|
||||||
|
# uvicorn's capture_signals() wires SIGINT/SIGTERM to self.handle_exit, so
|
||||||
|
# wrapping that method lets us set the shutdown flag before the normal exit
|
||||||
|
# path runs (this uvicorn version has no install_signal_handlers kwarg).
|
||||||
|
_uvicorn_handle_exit = server.handle_exit
|
||||||
|
|
||||||
|
def _handle_shutdown_signal(signum, frame):
|
||||||
|
_shutdown.request_shutdown()
|
||||||
|
_uvicorn_handle_exit(signum, frame)
|
||||||
|
|
||||||
|
server.handle_exit = _handle_shutdown_signal
|
||||||
|
|
||||||
|
server.run()
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Editor Agent API
|
||||||
|
|
||||||
|
How an agent (LLM or script) drives the PhotoCraft editor. There are three layers:
|
||||||
|
|
||||||
|
1. **Control API** — `window.geEditorControl`, a stable in-page command surface.
|
||||||
|
2. **NL planner** — `window.geEditorAI`, turns a prompt into control-API steps (LLM or basic mode).
|
||||||
|
3. **Bridge** — server-mediated SSE channel so a *server-side* agent can drive an **open** editor.
|
||||||
|
|
||||||
|
All three are owner-scoped by the app session; nothing is exposed to unauthenticated users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Control API (`window.geEditorControl`)
|
||||||
|
|
||||||
|
Installed when the editor opens. Version `EDITOR_CONTROL_VERSION`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
await window.geEditorControl.invoke(op, params) // { ok, op, result } | { ok:false, op, error }
|
||||||
|
await window.geEditorControl.run([{op, params}]) // sequential, stops on first failure
|
||||||
|
window.geEditorControl.list() // [{op, description}]
|
||||||
|
```
|
||||||
|
|
||||||
|
`invoke` is async. Every op that mutates the document pushes **one** undo entry via
|
||||||
|
`_saveState`, so a single op is one undo step.
|
||||||
|
|
||||||
|
### Op catalog
|
||||||
|
|
||||||
|
| Op | Params | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `document.info` | – | size, layers, mode |
|
||||||
|
| `document.importImage` | `{dataUrl,name,raster}` | load a raster image as a new layer |
|
||||||
|
| `layers.list` | – | top-most first |
|
||||||
|
| `layers.select` | `{id\|index\|name}` | index is top-most-first |
|
||||||
|
| `layers.addRaster` | – | new empty pixel layer |
|
||||||
|
| `layers.addAdjustment` | `{type,params}` | adjustment layer |
|
||||||
|
| `layers.duplicate` / `layers.delete` | – | on the active/selected layer(s) |
|
||||||
|
| `layers.setOpacity` | `{id?,opacity 0-1}` | |
|
||||||
|
| `layers.setBlend` | `{id?,blendMode\|mode}` | |
|
||||||
|
| `layers.group` / `layers.ungroup` | – | |
|
||||||
|
| `selection.selectAll` / `deselect` / `invert` | – | |
|
||||||
|
| `selection.rectangle` / `selection.ellipse` | `{x,y,w,h}` | document space |
|
||||||
|
| `selection.subject` | `{label}` | SAM text grounding (falls back to a centred rect) |
|
||||||
|
| `selection.face` | – | SAM "face" (falls back to an upper-centre ellipse) |
|
||||||
|
| `selection.sky` | – | SAM "sky" (falls back to a top-band colour range) |
|
||||||
|
| `selection.background` | `{fuzziness}` | border colour union |
|
||||||
|
| `selection.samText` | `{text}` | SAM text grounding only (throws without SAM) |
|
||||||
|
| `selection.colorRange` | `{color,fuzziness}` | |
|
||||||
|
| `selection.hasMask` | – | `{active, space}` |
|
||||||
|
| `adjustments.apply` | `{type,params}` | adjustment layer; camelCase types accepted |
|
||||||
|
| `adjustments.applyMasked` | `{brightness,contrast,saturation,warmth,invert}` | pixel pass, masked if a selection exists else whole layer |
|
||||||
|
| `filters.apply` | `{id}` | opens the slider dialog and applies |
|
||||||
|
| `filters.applyMasked` | `{id,radius,amount,invert}` | `id`: blur-gaussian, sharpen, unsharp-mask |
|
||||||
|
| `transform.start` / `transform.apply` / `transform.cancel` | – | |
|
||||||
|
| `text.add` | `{text,x,y,color,size,font,align}` | |
|
||||||
|
| `history.undo` / `history.redo` | – | |
|
||||||
|
| `history.mark` | – | returns current undo depth (for grouping) |
|
||||||
|
| `history.collapse` | `{mark,label}` | collapses entries above `mark` into one |
|
||||||
|
| `export.png` | – | `{dataUrl}` of the flattened document |
|
||||||
|
| `export.gallery` | – | saves a copy back to the gallery |
|
||||||
|
| `screenshot` | – | `{dataUrl,width,height}` of the composited canvas |
|
||||||
|
|
||||||
|
Param conventions the planner must respect:
|
||||||
|
|
||||||
|
- `adjustments.applyMasked`: `brightness`/`contrast`/`warmth` are **-100..100** (0 neutral);
|
||||||
|
`saturation` is a **multiplier** (1 neutral; values >3 are treated as percentages).
|
||||||
|
- `adjustments.apply` `type` aliases are normalised (`blackAndWhite` -> `black-white`).
|
||||||
|
- `color-balance` params are `{shadows:{r,g,b}, midtones:{r,g,b}, highlights:{r,g,b}}`;
|
||||||
|
a bare number is coerced to `{r:n,g:n,b:n}`.
|
||||||
|
- A masked op with **no** active selection degrades to a whole-layer edit (does not throw).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. NL planner (`window.geEditorAI`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await window.geEditorAI.execute('blur the background behind the person', { onStep });
|
||||||
|
// exec: window.geEditorAI.execute('blur the background behind the person', { onStep });
|
||||||
|
// res = { ok, prompt, reply, planner, basicMode, steps, trace, failed }
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow (`static/js/editor/nl-edit.js`):
|
||||||
|
|
||||||
|
1. Read document state + a **before** screenshot.
|
||||||
|
2. Ask the **server planner** (`POST /api/editor/plan`) with the prompt, state and
|
||||||
|
screenshot. The client passes its strong guidance via `state.planner_guidance`.
|
||||||
|
If the route is unavailable it falls back to `localPlan` (regex — labelled
|
||||||
|
**"basic mode"** in the UI).
|
||||||
|
3. `sanitizeSteps()`: drop no-op/export steps and, if a masked op has no preceding
|
||||||
|
`selection.*`, insert the region implied by the prompt (face/sky/background/subject).
|
||||||
|
4. `history.mark`, then run each step, screenshotting after each one.
|
||||||
|
5. **Self-check** (server plans only, max 3 rounds): send the after screenshot back in
|
||||||
|
`mode:"check"` with the original intent. If not satisfied, **revert** the previous
|
||||||
|
attempt (undo its history entries) and apply the model's **full corrected** plan.
|
||||||
|
6. `history.collapse` — the whole request becomes a single undoable step.
|
||||||
|
|
||||||
|
UI: the Quick Edit box routes multi-clause / "and ..." prompts to this executor and
|
||||||
|
streams `Step i/n: <op>` into its status line; a `(basic mode)` suffix marks the offline planner.
|
||||||
|
|
||||||
|
### Server route
|
||||||
|
|
||||||
|
`POST /api/editor/plan` (HARNESS, `routes/editor_agent_routes.py`)
|
||||||
|
|
||||||
|
```
|
||||||
|
{ prompt, state, mode?: "plan"|"check", screenshot?: "data:image/..." }
|
||||||
|
-> { reply, planner, steps: [{op, params}], done? }
|
||||||
|
```
|
||||||
|
|
||||||
|
Owner-scoped, authenticated. Uses the configured default/utility model with vision.
|
||||||
|
If the model call is unavailable it returns a deterministic `planner:"fallback"` plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Bridge (server agent drives an open editor)
|
||||||
|
|
||||||
|
`static/js/editor/editor-bridge.js` subscribes the open editor to the server queue.
|
||||||
|
Enabled automatically when the editor opens (`window.__geEditorBridge`).
|
||||||
|
|
||||||
|
- Subscribe: `GET /api/editor/bridge/events?session=<id>` — SSE, `data: {id,op,params}` (keepalive every 15s).
|
||||||
|
- The page runs the op via the control API and replies:
|
||||||
|
`POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`.
|
||||||
|
- Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`.
|
||||||
|
With `wait:true` the call blocks and returns the editor's result.
|
||||||
|
|
||||||
|
Round-trip example (authenticated; `<session>` = `window.__geEditorBridge.session`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \
|
||||||
|
-H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \
|
||||||
|
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8000}'
|
||||||
|
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
Session scoping is `owner:session`, so an agent can only drive editors owned by the
|
||||||
|
same user. The bridge is a **browser-only** flow: it requires an editor page to be open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SAM dependency
|
||||||
|
|
||||||
|
`selection.subject` / `selection.face` / `selection.sky` / `selection.samText` use the
|
||||||
|
server SAM mask tool (`POST /api/image/mask`). When the SAM tools are **not installed**
|
||||||
|
the editor raises *"Object mask tools are not installed"* and the control op falls back
|
||||||
|
to a **coarse approximation** (centred rect / upper-centre ellipse / top-band colour range).
|
||||||
|
On a plain background the approximation is acceptable; on real photos it leaks, producing
|
||||||
|
visible rectangular/elliptical artifacts. Install the Cookbook SAM mask dependencies for
|
||||||
|
true subject/face/sky segmentation.
|
||||||
|
|
||||||
|
`selection.colorRange` and `selection.background` never need SAM (pure colour matching),
|
||||||
|
but they are also coarse on busy scenes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Two ways for an agent to use this — pick one
|
||||||
|
|
||||||
|
### Option A — built-in tool (in-process)
|
||||||
|
|
||||||
|
Register an editor-control tool inside the app (same process as the model) that calls the
|
||||||
|
bridge/control API directly. Uses the app's normal login; nothing new is exposed. This is
|
||||||
|
the rejected stdio-MCP design's replacement: a server-side tool that talks to the bridge
|
||||||
|
needs either an internal token (auth-bypass) or to run in-process. **Decision pending (user).**
|
||||||
|
|
||||||
|
### Option B — browser-only flow (works today)
|
||||||
|
|
||||||
|
No new server tool. An external agent drives an open editor purely through the bridge
|
||||||
|
endpoints using the logged-in owner's session. Restricted to the editor the user has open.
|
||||||
|
This is the flow demonstrated above and requires **no** auth-bypass.
|
||||||
|
|
||||||
|
A stdio `photo_editor` MCP server was prototyped and **rejected** because the built-in MCP
|
||||||
|
subprocess has no internal token; wiring it would require expanding an auth bypass.
|
||||||
|
The two viable options above are left for the user to choose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Known limitations / honest grading
|
||||||
|
|
||||||
|
Live run against real 7011 with the server LLM planner (7 prompts): all 7 executed
|
||||||
|
(`planner:"llm"`). Graded by eye:
|
||||||
|
|
||||||
|
- PASS: black & white portrait; warm cinematic; sharper + more vivid; text title.
|
||||||
|
- FAIL (region-masked, SAM-bound): "dramatic sky" (global over-saturation instead of a
|
||||||
|
dramatic, masked sky), "blur the background" (coarse mask leaks; visible rectangle),
|
||||||
|
"brighten the face" (coarse ellipse/rect artifact).
|
||||||
|
|
||||||
|
Whole-image edits are reliable; subject/face/sky region edits need SAM. The self-check
|
||||||
|
loop works (revert-and-replan) but the region cases spend all 3 iterations.
|
||||||
|
|
||||||
|
## 7. Theming (control kit)
|
||||||
|
|
||||||
|
The editor's interactive controls (ranges, number fields, selects, checkboxes) share a
|
||||||
|
single themed kit: `static/js/editor/control-kit.js` adds `.ge-ctl-*` classes scoped to
|
||||||
|
the editor roots (`#gallery-editor-container`, `.ge-adj-popup`, `.ge-filter-overlay`,
|
||||||
|
`.ge-canvas-size-overlay`, `.ge-float-window`, `.ge-transform-popup`), and
|
||||||
|
`static/css/13-image-editor.css` (section "Editor control kit") styles them from the app
|
||||||
|
design tokens (`static/css/00-tokens.css`) to match the app sliders/selects. It keeps
|
||||||
|
range fills in sync and attaches a **scrub-on-label** affordance to numeric fields
|
||||||
|
(drag the field's label to change the value; arrow keys work, Shift = x10).
|
||||||
|
|
||||||
|
**The PhotoCraft editor is dark-only.** `:root.light` flips the global tokens, but
|
||||||
|
`13-image-editor.css` has no `.light` handling and the editor relies on dark surfaces,
|
||||||
|
so light mode renders a garbled mix. Unlike the rest of Odysseus, the editor follows
|
||||||
|
Photoshop and is not offered in a light theme.
|
||||||
@@ -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`.
|
||||||
@@ -112,7 +112,7 @@ export function createCardInteractions({
|
|||||||
if (state.selectMode) {
|
if (state.selectMode) {
|
||||||
body.querySelectorAll('.note-card').forEach(card => {
|
body.querySelectorAll('.note-card').forEach(card => {
|
||||||
card.addEventListener('click', (e) => {
|
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();
|
e.stopPropagation();
|
||||||
tapToEditOrSelect(card);
|
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-due" value="${note?.due_date || ''}" />
|
||||||
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
|
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
|
||||||
</div>
|
</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">
|
<div class="note-form-body">
|
||||||
${type === 'note'
|
${type === 'note'
|
||||||
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
|
? `<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();
|
form.querySelector('.note-form-image-wrap')?.remove();
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'note-form-image-wrap';
|
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
|
// Insert AFTER the whole header (a flex-row), not after the
|
||||||
// title input itself : otherwise the image lands as a sibling
|
// title input itself : otherwise the image lands as a sibling
|
||||||
// of the title inside the header and flex puts them side-by-side.
|
// 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);
|
url = url.slice(0, -1);
|
||||||
}
|
}
|
||||||
const href = url.startsWith('www.') ? `https://${url}` : url;
|
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) : '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-10
@@ -1185,6 +1185,9 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
|
|||||||
"subject": subject,
|
"subject": subject,
|
||||||
"from": sender_name or sender_addr or sender,
|
"from": sender_name or sender_addr or sender,
|
||||||
"from_address": sender_addr,
|
"from_address": sender_addr,
|
||||||
|
"to": str(row.get("to") or headers.get("To") or ""),
|
||||||
|
"cc": str(row.get("cc") or headers.get("Cc") or ""),
|
||||||
|
"references": str(row.get("references") or headers.get("References") or ""),
|
||||||
"date": date_str,
|
"date": date_str,
|
||||||
"date_epoch": date_epoch,
|
"date_epoch": date_epoch,
|
||||||
"summary": body[:240],
|
"summary": body[:240],
|
||||||
@@ -1557,6 +1560,7 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
|||||||
if not isinstance(rows, list):
|
if not isinstance(rows, list):
|
||||||
return False
|
return False
|
||||||
owner = _current_owner()
|
owner = _current_owner()
|
||||||
|
matches = []
|
||||||
for index, row in enumerate(rows, start=1):
|
for index, row in enumerate(rows, start=1):
|
||||||
if not isinstance(row, dict):
|
if not isinstance(row, dict):
|
||||||
continue
|
continue
|
||||||
@@ -1568,14 +1572,19 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
|||||||
continue
|
continue
|
||||||
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
|
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
|
||||||
continue
|
continue
|
||||||
for key, value in updates.items():
|
if not _fixture_row_matches_account(_fixture_email_record(row, index, owner or row_owner), account):
|
||||||
if value is None:
|
continue
|
||||||
row.pop(key, None)
|
matches.append(row)
|
||||||
else:
|
if len(matches) != 1:
|
||||||
row[key] = value
|
return False
|
||||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
row = matches[0]
|
||||||
return True
|
for key, value in updates.items():
|
||||||
return False
|
if value is None:
|
||||||
|
row.pop(key, None)
|
||||||
|
else:
|
||||||
|
row[key] = value
|
||||||
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ── Tool implementations ──
|
# ── Tool implementations ──
|
||||||
@@ -2925,7 +2934,9 @@ def _create_email_draft_document(
|
|||||||
|
|
||||||
def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None):
|
def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None):
|
||||||
"""Create a threaded Odysseus reply draft document. Does not send."""
|
"""Create a threaded Odysseus reply draft document. Does not send."""
|
||||||
fixture = _fixture_email_action_target(uid=uid, folder=folder, account=account)
|
fixture = _fixture_read_email(uid=uid, folder=folder, account=account)
|
||||||
|
if _fixture_email_enabled() and (fixture is None or fixture.get("error")):
|
||||||
|
return fixture or {"error": "No email fixture is available for the authenticated owner"}
|
||||||
if fixture is not None:
|
if fixture is not None:
|
||||||
sender = str(fixture.get("from_address") or fixture.get("from") or "")
|
sender = str(fixture.get("from_address") or fixture.get("from") or "")
|
||||||
_, sender_addr = email.utils.parseaddr(sender)
|
_, sender_addr = email.utils.parseaddr(sender)
|
||||||
@@ -3178,7 +3189,9 @@ def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
|
|||||||
|
|
||||||
def _set_flag(uid, folder, flag, add=True, account=None):
|
def _set_flag(uid, folder, flag, add=True, account=None):
|
||||||
"""Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted)."""
|
"""Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted)."""
|
||||||
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is not None:
|
if _fixture_email_enabled():
|
||||||
|
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is None:
|
||||||
|
return False
|
||||||
if flag == "\\Seen":
|
if flag == "\\Seen":
|
||||||
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
|
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
|
||||||
if flag == "\\Answered":
|
if flag == "\\Answered":
|
||||||
|
|||||||
@@ -571,6 +571,50 @@ async def auto_name_session(session_manager, sess):
|
|||||||
|
|
||||||
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
||||||
model_name = str(getattr(sess, "model", "") or "")
|
model_name = str(getattr(sess, "model", "") or "")
|
||||||
|
|
||||||
|
# The spec ("Ajax") model names its own chats from the user's first
|
||||||
|
# prompt. This function only runs after the first response stream has
|
||||||
|
# settled (auto_name_session_after_stream), so titling does not
|
||||||
|
# contend with the visible answer. Thinking is disabled and the
|
||||||
|
# budget kept small so the model returns a bare title.
|
||||||
|
try:
|
||||||
|
from src.model_profiles import uses_odysseus_progressive_thinking
|
||||||
|
_is_spec_model = bool(model_name) and uses_odysseus_progressive_thinking(model_name)
|
||||||
|
except Exception:
|
||||||
|
_is_spec_model = False
|
||||||
|
if _is_spec_model and endpoint_url:
|
||||||
|
_headers = getattr(sess, "headers", None)
|
||||||
|
if isinstance(_headers, str):
|
||||||
|
try:
|
||||||
|
_headers = json.loads(_headers)
|
||||||
|
except Exception:
|
||||||
|
_headers = None
|
||||||
|
try:
|
||||||
|
title = await llm_call_async(
|
||||||
|
endpoint_url,
|
||||||
|
model_name,
|
||||||
|
[
|
||||||
|
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
|
||||||
|
{"role": "user", "content": first_msg},
|
||||||
|
],
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=32,
|
||||||
|
headers=_headers or None,
|
||||||
|
timeout=20,
|
||||||
|
thinking_mode="off",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[auto-name] spec title call failed for %s: %s", sess.id, e)
|
||||||
|
title = ""
|
||||||
|
title = str(title or "").strip().strip('"\'').strip()
|
||||||
|
from src.text_helpers import strip_think
|
||||||
|
title = strip_think(title, prose=False, prompt_echo=False)
|
||||||
|
if not title or len(title) >= 80 or "\n" in title:
|
||||||
|
title = fallback_session_title(first_msg)
|
||||||
|
session_manager.update_session_name(sess.id, title)
|
||||||
|
logger.info(f"Auto-named spec session {sess.id}: {title}")
|
||||||
|
return
|
||||||
|
|
||||||
if (
|
if (
|
||||||
"ttft" in model_name.lower()
|
"ttft" in model_name.lower()
|
||||||
or re.search(r":18\d{3}\b", endpoint_url)
|
or re.search(r":18\d{3}\b", endpoint_url)
|
||||||
|
|||||||
+96
-10
@@ -27,11 +27,13 @@ from src.llm_core import (
|
|||||||
from src.agent_loop import (
|
from src.agent_loop import (
|
||||||
stream_agent_loop,
|
stream_agent_loop,
|
||||||
_configured_model_tool_surface,
|
_configured_model_tool_surface,
|
||||||
|
_configured_model_tool_surface_raw,
|
||||||
_local_media_needs_browser_render,
|
_local_media_needs_browser_render,
|
||||||
_looks_like_workspace_coding_request,
|
_looks_like_workspace_coding_request,
|
||||||
)
|
)
|
||||||
from src.agent_loop import _normalize_ody_qwen_text_artifacts
|
from src.agent_loop import _normalize_ody_qwen_text_artifacts
|
||||||
from src import agent_runs
|
from src import agent_runs
|
||||||
|
from src import shutdown as _shutdown
|
||||||
from src.model_context import estimate_tokens
|
from src.model_context import estimate_tokens
|
||||||
from src.context_compactor import (
|
from src.context_compactor import (
|
||||||
apply_compaction_state,
|
apply_compaction_state,
|
||||||
@@ -2688,6 +2690,19 @@ def setup_chat_routes(
|
|||||||
logger.info("chat→agent auto-escalation: explicit private browser workflow")
|
logger.info("chat→agent auto-escalation: explicit private browser workflow")
|
||||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||||
active_doc_state = form_data.get("active_doc_state", "").strip().casefold()
|
active_doc_state = form_data.get("active_doc_state", "").strip().casefold()
|
||||||
|
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
||||||
|
# image-edit chat turn) may carry the open image-editor session id and a
|
||||||
|
# canvas snapshot so the editor tool family can drive it in-process.
|
||||||
|
editor_session = (
|
||||||
|
form_data.get("editor_session")
|
||||||
|
or form_data.get("editor_session_id")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
photo_editor_requested = (
|
||||||
|
str(form_data.get("photo_editor") or "").strip().lower()
|
||||||
|
in {"1", "true", "yes", "on"}
|
||||||
|
or bool(editor_session)
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
|
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
|
||||||
chat_mode, active_doc_id, active_doc_state,
|
chat_mode, active_doc_id, active_doc_state,
|
||||||
@@ -3270,7 +3285,34 @@ def setup_chat_routes(
|
|||||||
active_document=bool(active_doc), workspace=bool(workspace),
|
active_document=bool(active_doc), workspace=bool(workspace),
|
||||||
image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])),
|
image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])),
|
||||||
) if _use_turn_contract else frozenset()
|
) if _use_turn_contract else frozenset()
|
||||||
if 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
|
# Scoped photo_editor family: offered ONLY for an explicit editor
|
||||||
|
# request (Quick Edit popup / editor session) or an image-edit chat
|
||||||
|
# turn carrying an image. When active the turn offers just the editor
|
||||||
|
# tools plus ask_user, never the general tool list.
|
||||||
|
_photo_editor_active = bool(_use_turn_contract) and (
|
||||||
|
photo_editor_requested
|
||||||
|
or (
|
||||||
|
"image_editing" in _turn_capabilities
|
||||||
|
and bool(ctx.preprocessed.attachment_meta)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if _photo_editor_active:
|
||||||
|
if editor_session:
|
||||||
|
editor_context = {'role': 'system', 'content':
|
||||||
|
'The image editor is open (session ' + editor_session + '). Use run_editor_command, '
|
||||||
|
'plan_edit and get_editor_screenshot to inspect and change the current canvas, and '
|
||||||
|
'ai_edit_image for model-based edits. Apply the user\'s requested edit to the open '
|
||||||
|
'editor and confirm the result with a screenshot.'}
|
||||||
|
else:
|
||||||
|
editor_context = {'role': 'system', 'content':
|
||||||
|
'The user attached an image but no image editor is open, so the editor tools cannot '
|
||||||
|
'reach the canvas. Do not call editor tools. Reply briefly telling the user to open '
|
||||||
|
'the attached image in the editor (Quick Edit) and repeat the request; the editor '
|
||||||
|
'tools will then apply it.'}
|
||||||
|
ctx.messages.insert(0, editor_context)
|
||||||
|
if foreground_policy.enabled:
|
||||||
|
getattr(ctx, 'route_messages', ctx.messages).insert(0, dict(editor_context))
|
||||||
|
elif 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
|
||||||
image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta
|
image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta
|
||||||
if a.get('id') and str(a.get('mime') or '').startswith('image/')]
|
if a.get('id') and str(a.get('mime') or '').startswith('image/')]
|
||||||
if image_refs:
|
if image_refs:
|
||||||
@@ -3307,6 +3349,11 @@ def setup_chat_routes(
|
|||||||
# pasted revision request. This only offers permitted schemas;
|
# pasted revision request. This only offers permitted schemas;
|
||||||
# it never requires or performs a document mutation.
|
# it never requires or performs a document mutation.
|
||||||
_turn_capabilities = _turn_capabilities | {"documents"}
|
_turn_capabilities = _turn_capabilities | {"documents"}
|
||||||
|
# Scoped photo_editor family (see above): when active the turn offers
|
||||||
|
# just the editor tools plus ask_user, never the general tool list.
|
||||||
|
if _photo_editor_active:
|
||||||
|
_turn_capabilities = frozenset({"photo_editor"})
|
||||||
|
_active_turn_capabilities = _turn_capabilities
|
||||||
# Same decision that prepared the turn; it only stamps the contract
|
# Same decision that prepared the turn; it only stamps the contract
|
||||||
# inside the agent-contract branch below.
|
# inside the agent-contract branch below.
|
||||||
_clean_v3_preview = _compact_preview_turn
|
_clean_v3_preview = _compact_preview_turn
|
||||||
@@ -3556,9 +3603,22 @@ def setup_chat_routes(
|
|||||||
"document_editor": not {
|
"document_editor": not {
|
||||||
"manage_documents", "create_document", "edit_document", "update_document",
|
"manage_documents", "create_document", "edit_document", "update_document",
|
||||||
}.issubset(disabled_tools),
|
}.issubset(disabled_tools),
|
||||||
|
# A photo-editing chat turn can only reach a canvas when the image
|
||||||
|
# editor is actually open. The compact runtime uses this to reply
|
||||||
|
# (open the image in the editor) instead of looping on editor tools
|
||||||
|
# that have no canvas to drive.
|
||||||
|
"photo_editor_open": bool(editor_session),
|
||||||
}
|
}
|
||||||
# Capture permission state before schema selection/reconciliation.
|
# Capture permission state before schema selection/reconciliation.
|
||||||
# Only deterministic request intent supplies grants, never inventory.
|
# Only deterministic request intent supplies grants, never inventory.
|
||||||
|
# The strict router (spec) surface picks its tools in src/spec_router.py
|
||||||
|
# AFTER authority is built, so its admission is keyed off the surface.
|
||||||
|
try:
|
||||||
|
_spec_surface = _configured_model_tool_surface_raw(
|
||||||
|
getattr(sess, "endpoint_url", ""), getattr(sess, "model", ""), _user
|
||||||
|
) == "spec"
|
||||||
|
except Exception:
|
||||||
|
_spec_surface = False
|
||||||
_request_authority = request_authority_for_http(
|
_request_authority = request_authority_for_http(
|
||||||
request, message, owner=_user, session_id=session, workspace=workspace,
|
request, message, owner=_user, session_id=session, workspace=workspace,
|
||||||
history=_turn_history, policy=tool_policy,
|
history=_turn_history, policy=tool_policy,
|
||||||
@@ -3566,9 +3626,9 @@ def setup_chat_routes(
|
|||||||
active_document=bool(active_doc),
|
active_document=bool(active_doc),
|
||||||
image_attachment=any(str(a.get('mime') or '').startswith('image/')
|
image_attachment=any(str(a.get('mime') or '').startswith('image/')
|
||||||
for a in (ctx.preprocessed.attachment_meta or [])),
|
for a in (ctx.preprocessed.attachment_meta or [])),
|
||||||
capabilities=({'search_browser'} if (
|
capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if (
|
||||||
_explicit_browser_intent or _external_discovery_intent
|
_explicit_browser_intent or _external_discovery_intent
|
||||||
) else ()),
|
) else set())),
|
||||||
)
|
)
|
||||||
if exact_tool_approval is not None:
|
if exact_tool_approval is not None:
|
||||||
from src.agent_runtime.authority import RequestAuthority
|
from src.agent_runtime.authority import RequestAuthority
|
||||||
@@ -3758,7 +3818,9 @@ def setup_chat_routes(
|
|||||||
# personal manager; core shell/Web tools are not task
|
# personal manager; core shell/Web tools are not task
|
||||||
# fallbacks. Other turns retain warm and workspace tools.
|
# fallbacks. Other turns retain warm and workspace tools.
|
||||||
extra_tools=(
|
extra_tools=(
|
||||||
frozenset()
|
frozenset({"ask_user"})
|
||||||
|
if _active_turn_capabilities == frozenset({"photo_editor"})
|
||||||
|
else frozenset()
|
||||||
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
|
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
|
||||||
_active_turn_capabilities in (
|
_active_turn_capabilities in (
|
||||||
frozenset({"transcription"}), frozenset({"ocr"}),
|
frozenset({"transcription"}), frozenset({"ocr"}),
|
||||||
@@ -3960,7 +4022,7 @@ def setup_chat_routes(
|
|||||||
_heartbeat_counter = 0
|
_heartbeat_counter = 0
|
||||||
_last_progress = {}
|
_last_progress = {}
|
||||||
_sent_avg = False
|
_sent_avg = False
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
status = research_handler.get_status(session)
|
status = research_handler.get_status(session)
|
||||||
if not status or status["status"] != "running":
|
if not status or status["status"] != "running":
|
||||||
break
|
break
|
||||||
@@ -4222,8 +4284,6 @@ def setup_chat_routes(
|
|||||||
# indicator, but don't fold them into the saved
|
# indicator, but don't fold them into the saved
|
||||||
# reply (mirrors the rewrite path below).
|
# reply (mirrors the rewrite path below).
|
||||||
if data.get("thinking"):
|
if data.get("thinking"):
|
||||||
if thinking_mode == "off":
|
|
||||||
continue
|
|
||||||
thinking_response += data["delta"]
|
thinking_response += data["delta"]
|
||||||
else:
|
else:
|
||||||
full_response += data["delta"]
|
full_response += data["delta"]
|
||||||
@@ -4667,8 +4727,6 @@ def setup_chat_routes(
|
|||||||
# Forward them for the live indicator, but keep
|
# Forward them for the live indicator, but keep
|
||||||
# them out of the saved reply (same as chat mode).
|
# them out of the saved reply (same as chat mode).
|
||||||
if data.get("thinking"):
|
if data.get("thinking"):
|
||||||
if thinking_mode == "off":
|
|
||||||
continue
|
|
||||||
thinking_response += data["delta"]
|
thinking_response += data["delta"]
|
||||||
else:
|
else:
|
||||||
full_response = _render_state.content
|
full_response = _render_state.content
|
||||||
@@ -4852,6 +4910,25 @@ def setup_chat_routes(
|
|||||||
_terminal_saved = True
|
_terminal_saved = True
|
||||||
if _saved_id:
|
if _saved_id:
|
||||||
yield f'data: {json.dumps(_render_state.message_saved(_saved_id))}\n\n'
|
yield f'data: {json.dumps(_render_state.message_saved(_saved_id))}\n\n'
|
||||||
|
run_post_response_tasks(
|
||||||
|
sess, session_manager, session, message, _saveable_no_tool_response,
|
||||||
|
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
||||||
|
incognito=incognito, compare_mode=compare_mode,
|
||||||
|
character_name=ctx.preset.character_name,
|
||||||
|
agent_rounds=_agent_rounds,
|
||||||
|
agent_tool_calls=_agent_tool_calls,
|
||||||
|
skills_manager=skills_manager,
|
||||||
|
owner=_user,
|
||||||
|
extract_skills=(
|
||||||
|
user_requested_agent
|
||||||
|
and not tool_approval_continuation
|
||||||
|
),
|
||||||
|
allow_background_extraction=_post_response_extraction_allowed(
|
||||||
|
tools_blocked=tool_policy.block_all_tool_calls,
|
||||||
|
tool_approval_continuation=tool_approval_continuation,
|
||||||
|
client_runtime_context=client_runtime_context,
|
||||||
|
),
|
||||||
|
)
|
||||||
yield f'data: {json.dumps(_metrics_event)}\n\n'
|
yield f'data: {json.dumps(_metrics_event)}\n\n'
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
yield chunk
|
yield chunk
|
||||||
@@ -4962,6 +5039,8 @@ def setup_chat_routes(
|
|||||||
raises before reaching a mode-specific finally block."""
|
raises before reaching a mode-specific finally block."""
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_with_save():
|
async for chunk in stream_with_save():
|
||||||
|
if _shutdown.is_shutting_down():
|
||||||
|
break
|
||||||
yield chunk
|
yield chunk
|
||||||
finally:
|
finally:
|
||||||
_active_streams.pop(session, None)
|
_active_streams.pop(session, None)
|
||||||
@@ -5056,7 +5135,12 @@ def setup_chat_routes(
|
|||||||
if rec is None:
|
if rec is None:
|
||||||
if agent_runs.is_active(session_id):
|
if agent_runs.is_active(session_id):
|
||||||
return {"status": "streaming", "detached": True}
|
return {"status": "streaming", "detached": True}
|
||||||
raise HTTPException(404, "No active stream for this session")
|
# No active stream: report an explicit idle state with HTTP 200.
|
||||||
|
# A 404 here made every session load look like a failed request in
|
||||||
|
# the browser console. The client distinguishes "no stream" by the
|
||||||
|
# body (status != "streaming"), and still tolerates a 404 from an
|
||||||
|
# older server, so this stays backward compatible.
|
||||||
|
return {"status": "idle"}
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -5155,6 +5239,8 @@ def setup_chat_routes(
|
|||||||
max_tokens=0,
|
max_tokens=0,
|
||||||
tools=None,
|
tools=None,
|
||||||
):
|
):
|
||||||
|
if _shutdown.is_shutting_down():
|
||||||
|
break
|
||||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||||
try:
|
try:
|
||||||
data = json.loads(chunk[6:])
|
data = json.loads(chunk[6:])
|
||||||
|
|||||||
@@ -1185,7 +1185,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
async def ai_tidy_documents(request: Request) -> Dict[str, Any]:
|
async def ai_tidy_documents(request: Request) -> Dict[str, Any]:
|
||||||
"""Use AI to judge if documents are junk/test/accidental, then delete them.
|
"""Use AI to judge if documents are junk/test/accidental, then delete them.
|
||||||
Caches verdicts so previously-reviewed docs are skipped."""
|
Caches verdicts so previously-reviewed docs are skipped."""
|
||||||
from src.task_endpoint import resolve_task_endpoint
|
from src.task_endpoint import resolve_task_endpoint, task_llm_call_async
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
|
|
||||||
@@ -1228,13 +1228,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
+ "\n".join(doc_list)
|
+ "\n".join(doc_list)
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await llm_call_async(
|
response = await task_llm_call_async(
|
||||||
url, model,
|
|
||||||
[{"role": "system", "content": "You classify documents as junk or keep. Respond only with a JSON array."},
|
[{"role": "system", "content": "You classify documents as junk or keep. Respond only with a JSON array."},
|
||||||
{"role": "user", "content": prompt}],
|
{"role": "user", "content": prompt}],
|
||||||
|
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||||
|
owner=user or None,
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
max_tokens=200,
|
max_tokens=200,
|
||||||
headers=headers,
|
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@ from starlette.concurrency import run_in_threadpool
|
|||||||
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR
|
||||||
|
from src import shutdown as _shutdown
|
||||||
from src.path_confinement import confine
|
from src.path_confinement import confine
|
||||||
|
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
@@ -1834,13 +1835,55 @@ def setup_email_routes():
|
|||||||
return None
|
return None
|
||||||
return v[1]
|
return v[1]
|
||||||
|
|
||||||
|
_LIST_STALE = {}
|
||||||
|
|
||||||
|
def _list_stale_get(key):
|
||||||
|
return _LIST_STALE.get(key)
|
||||||
|
|
||||||
|
def _list_stale_put(key, value):
|
||||||
|
_LIST_STALE[key] = value
|
||||||
|
if len(_LIST_STALE) > 96:
|
||||||
|
for k in list(_LIST_STALE.keys())[:-48]:
|
||||||
|
_LIST_STALE.pop(k, None)
|
||||||
|
|
||||||
|
_LIST_REFRESHING = set()
|
||||||
|
|
||||||
def _list_cache_put(key, value):
|
def _list_cache_put(key, value):
|
||||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||||
|
# Also snapshot the last-good response for stale-while-revalidate.
|
||||||
|
_list_stale_put(key, value)
|
||||||
# Cap size
|
# Cap size
|
||||||
if len(_LIST_CACHE) > 64:
|
if len(_LIST_CACHE) > 64:
|
||||||
for k in list(_LIST_CACHE.keys())[:-32]:
|
for k in list(_LIST_CACHE.keys())[:-32]:
|
||||||
_LIST_CACHE.pop(k, None)
|
_LIST_CACHE.pop(k, None)
|
||||||
|
|
||||||
|
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
|
||||||
|
"""Refresh a list snapshot off the event loop, at most once per key.
|
||||||
|
|
||||||
|
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
|
||||||
|
the TTL cache and the stale snapshot so the next list view is warm.
|
||||||
|
"""
|
||||||
|
if cache_key in _LIST_REFRESHING:
|
||||||
|
return
|
||||||
|
_LIST_REFRESHING.add(cache_key)
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
refreshed = _list_emails_sync(*sync_args)
|
||||||
|
if refreshed and not refreshed.get("error"):
|
||||||
|
_list_cache_put(cache_key, refreshed)
|
||||||
|
try:
|
||||||
|
_schedule_recent_email_warm(
|
||||||
|
refreshed.get("emails") or [], folder, account_id, owner)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as exc: # pragma: no cover - background best effort
|
||||||
|
logger.debug("email list background refresh failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
_LIST_REFRESHING.discard(cache_key)
|
||||||
|
|
||||||
|
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
||||||
|
|
||||||
def _folder_cache_get(account_id, owner):
|
def _folder_cache_get(account_id, owner):
|
||||||
key = (account_id or "", owner or "")
|
key = (account_id or "", owner or "")
|
||||||
v = _FOLDER_CACHE.get(key)
|
v = _FOLDER_CACHE.get(key)
|
||||||
@@ -2867,6 +2910,46 @@ def setup_email_routes():
|
|||||||
owner, account_id or "", folder, filter, limit, offset, elapsed_ms,
|
owner, account_id or "", folder, filter, limit, offset, elapsed_ms,
|
||||||
)
|
)
|
||||||
return cached
|
return cached
|
||||||
|
# Stale-while-revalidate: never block a plain list view on IMAP while
|
||||||
|
# we already hold a recent snapshot or a durable index. Serve it now
|
||||||
|
# with a `stale` marker and refresh in the background so the next view
|
||||||
|
# is warm. Blocking IMAP only remains for a genuine cold start with no
|
||||||
|
# snapshot and no indexed rows (e.g. a brand-new account/folder).
|
||||||
|
if not from_addr and not has_attachments and not (date_from or date_to):
|
||||||
|
snapshot = _list_stale_get(ck)
|
||||||
|
if snapshot is None:
|
||||||
|
try:
|
||||||
|
indexed_emails, indexed_total, indexed_at = _email_index_list(
|
||||||
|
owner, account_id, folder, filter, limit, offset, False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
indexed_emails, indexed_total, indexed_at = [], 0, None
|
||||||
|
if indexed_total:
|
||||||
|
snapshot = {
|
||||||
|
"emails": indexed_emails,
|
||||||
|
"total": indexed_total,
|
||||||
|
"folder": folder,
|
||||||
|
"offset": offset,
|
||||||
|
"sync": {
|
||||||
|
"source": "index",
|
||||||
|
"indexed": indexed_total,
|
||||||
|
"updated_at": indexed_at,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if snapshot is not None:
|
||||||
|
payload = dict(snapshot)
|
||||||
|
payload["emails"] = list(payload.get("emails") or [])
|
||||||
|
_hide_unlinked_calendar_tags(payload["emails"])
|
||||||
|
sync_meta = dict(payload.get("sync") or {})
|
||||||
|
sync_meta["source"] = "cache_stale" if _list_stale_get(ck) is not None else "index"
|
||||||
|
sync_meta["stale"] = True
|
||||||
|
payload["sync"] = sync_meta
|
||||||
|
_schedule_list_refresh(
|
||||||
|
(folder, limit, offset, filter, account_id, from_addr,
|
||||||
|
bool(has_attachments), owner, True, date_from or "", date_to or ""),
|
||||||
|
ck, folder, account_id, owner,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
result = await _asyncio.to_thread(
|
result = await _asyncio.to_thread(
|
||||||
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
||||||
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
||||||
@@ -6717,7 +6800,7 @@ def setup_email_routes():
|
|||||||
await queue.put({'type': 'result', **result})
|
await queue.put({'type': 'result', **result})
|
||||||
task = asyncio.create_task(run())
|
task = asyncio.create_task(run())
|
||||||
try:
|
try:
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
item = await queue.get()
|
item = await queue.get()
|
||||||
yield 'data: ' + json.dumps(item) + '\n\n'
|
yield 'data: ' + json.dumps(item) + '\n\n'
|
||||||
if item.get('type') == 'result':
|
if item.get('type') == 'result':
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+22
-7
@@ -473,7 +473,7 @@ def _truthy(value: str | None) -> bool:
|
|||||||
|
|
||||||
_ENDPOINT_KINDS = {"auto", "local", "api", "proxy"}
|
_ENDPOINT_KINDS = {"auto", "local", "api", "proxy"}
|
||||||
_REFRESH_MODES = {"auto", "manual", "disabled"}
|
_REFRESH_MODES = {"auto", "manual", "disabled"}
|
||||||
_MODEL_TOOL_MODES = {"none", "compact", "full"}
|
_MODEL_TOOL_MODES = {"none", "compact", "full", "spec"}
|
||||||
_MODEL_TOOL_MODE_ALIASES = {
|
_MODEL_TOOL_MODE_ALIASES = {
|
||||||
"regular": "full",
|
"regular": "full",
|
||||||
"odysseus_compact": "compact",
|
"odysseus_compact": "compact",
|
||||||
@@ -553,10 +553,10 @@ def _endpoint_refresh_timeout(ep: Any, category: str) -> float:
|
|||||||
val = 0
|
val = 0
|
||||||
if val > 0:
|
if val > 0:
|
||||||
return float(max(1, min(60, val)))
|
return float(max(1, min(60, val)))
|
||||||
# llama.cpp and other local OpenAI-compatible servers can block briefly
|
# Keep the background refresh short so a single dead endpoint cannot stall
|
||||||
# while warming/loading. A 2s local timeout makes working endpoints flicker
|
# the model picker. A timed-out refresh never clears a non-empty model
|
||||||
# offline before /v1/models is ready.
|
# cache, so a briefly-warming local server is simply retried next round.
|
||||||
return 10.0 if category == "local" else 2.0
|
return 2.0
|
||||||
|
|
||||||
|
|
||||||
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
|
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
|
||||||
@@ -1511,6 +1511,9 @@ def setup_model_routes(model_discovery):
|
|||||||
_refresh_inflight = {"v": False, "done": None} # coarse single-flight guard
|
_refresh_inflight = {"v": False, "done": None} # coarse single-flight guard
|
||||||
_REFRESH_FAILURE_BASE = 300.0
|
_REFRESH_FAILURE_BASE = 300.0
|
||||||
_REFRESH_FAILURE_MAX = 3600.0
|
_REFRESH_FAILURE_MAX = 3600.0
|
||||||
|
# Even a user-triggered refresh must not re-probe an endpoint that just
|
||||||
|
# failed/timed out on every click; keep a short cooldown.
|
||||||
|
_REFRESH_FAILURE_MIN = 60.0
|
||||||
|
|
||||||
def _refresh_key(base: str, api_key: Optional[str], provider_auth_id: Optional[str] = None) -> str:
|
def _refresh_key(base: str, api_key: Optional[str], provider_auth_id: Optional[str] = None) -> str:
|
||||||
# Session-backed endpoints carry no static key; include their auth id
|
# Session-backed endpoints carry no static key; include their auth id
|
||||||
@@ -1558,14 +1561,20 @@ def setup_model_routes(model_discovery):
|
|||||||
if mode in ("manual", "disabled") and not force:
|
if mode in ("manual", "disabled") and not force:
|
||||||
return False, info
|
return False, info
|
||||||
fails = int(state.get("fail_count") or 0)
|
fails = int(state.get("fail_count") or 0)
|
||||||
if fails and not force:
|
if fails:
|
||||||
last_failure = float(state.get("last_failure") or 0.0)
|
last_failure = float(state.get("last_failure") or 0.0)
|
||||||
empty_local = (
|
empty_local = (
|
||||||
not cached
|
not cached
|
||||||
and category == "local"
|
and category == "local"
|
||||||
and str(getattr(ep, "id", "") or "").startswith("local-")
|
and str(getattr(ep, "id", "") or "").startswith("local-")
|
||||||
)
|
)
|
||||||
if now - last_failure < _failure_delay(fails, empty_local=empty_local):
|
delay = _failure_delay(fails, empty_local=empty_local)
|
||||||
|
if force:
|
||||||
|
# A user-triggered refresh gets a short cooldown instead of the
|
||||||
|
# long background backoff, but still skips a just-failed
|
||||||
|
# endpoint so repeated clicks cannot stall the picker.
|
||||||
|
delay = min(delay, _REFRESH_FAILURE_MIN)
|
||||||
|
if now - last_failure < delay:
|
||||||
return False, info
|
return False, info
|
||||||
if cached and not force:
|
if cached and not force:
|
||||||
interval = _endpoint_refresh_interval(ep, category)
|
interval = _endpoint_refresh_interval(ep, category)
|
||||||
@@ -3089,4 +3098,10 @@ def setup_model_routes(model_discovery):
|
|||||||
|
|
||||||
router._should_refresh_endpoint = _should_refresh_endpoint
|
router._should_refresh_endpoint = _should_refresh_endpoint
|
||||||
router._search_endpoint_catalog = search_endpoint_catalog
|
router._search_endpoint_catalog = search_endpoint_catalog
|
||||||
|
# Exposed for tests: the in-memory backoff state, its key function, and the
|
||||||
|
# background refresh driver (probes endpoints in parallel with the short
|
||||||
|
# per-endpoint timeout).
|
||||||
|
router._refresh_state = _refresh_state
|
||||||
|
router._refresh_key = _refresh_key
|
||||||
|
router._refresh_caches_bg = _refresh_caches_bg
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from src.endpoint_resolver import resolve_endpoint
|
|||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
from src.auth_helpers import _auth_disabled, get_current_user
|
||||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||||
from src.constants import DEEP_RESEARCH_DIR
|
from src.constants import DEEP_RESEARCH_DIR
|
||||||
|
from src import shutdown as _shutdown
|
||||||
|
|
||||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||||
|
|
||||||
@@ -285,11 +286,22 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
async def research_status(session_id: str, request: Request):
|
async def research_status(session_id: str, request: Request):
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
_validate_session_id(session_id)
|
_validate_session_id(session_id)
|
||||||
|
# No in-flight or persisted research for this session is a normal state
|
||||||
|
# (most sessions have none), not an error. Return HTTP 200 with an
|
||||||
|
# explicit empty status so loading a session does not log a spurious
|
||||||
|
# 404; every client treats a non-"running" status as idle anyway.
|
||||||
|
# A session whose research exists but belongs to ANOTHER user stays a
|
||||||
|
# 404, so we do not leak the existence of someone else's research.
|
||||||
if not _owns_in_memory(session_id, user):
|
if not _owns_in_memory(session_id, user):
|
||||||
raise HTTPException(404, "No research found for this session")
|
exists = session_id in getattr(research_handler, "_active_tasks", {})
|
||||||
|
if not exists:
|
||||||
|
exists = _find_research_path(session_id) is not None
|
||||||
|
if exists:
|
||||||
|
raise HTTPException(404, "No research found for this session")
|
||||||
|
return {"status": "none"}
|
||||||
status = research_handler.get_status(session_id)
|
status = research_handler.get_status(session_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(404, "No research found for this session")
|
return {"status": "none"}
|
||||||
try:
|
try:
|
||||||
source_state = research_handler.get_source_state(session_id)
|
source_state = research_handler.get_source_state(session_id)
|
||||||
if isinstance(source_state, str) and source_state:
|
if isinstance(source_state, str) and source_state:
|
||||||
@@ -657,7 +669,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
raise HTTPException(404, "No research found for this session")
|
raise HTTPException(404, "No research found for this session")
|
||||||
async def _generate():
|
async def _generate():
|
||||||
last_payload = None
|
last_payload = None
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
status = research_handler.get_status(session_id)
|
status = research_handler.get_status(session_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||||
|
|||||||
@@ -1320,7 +1320,7 @@ def setup_session_routes(
|
|||||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||||
users can clean junk without spending tokens.
|
users can clean junk without spending tokens.
|
||||||
"""
|
"""
|
||||||
from src.llm_core import llm_call
|
from src.llm_core import llm_call, llm_call_with_fallback
|
||||||
from src.utility_effort import effort_for_call
|
from src.utility_effort import effort_for_call
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
single_user_mode = not user and _auth_disabled()
|
single_user_mode = not user and _auth_disabled()
|
||||||
@@ -1467,7 +1467,7 @@ def setup_session_routes(
|
|||||||
return {"status": "skipped", "reason": "No unfiled sessions to sort"}
|
return {"status": "skipped", "reason": "No unfiled sessions to sort"}
|
||||||
|
|
||||||
# Pick an endpoint — prefer admin-configured task endpoint
|
# Pick an endpoint — prefer admin-configured task endpoint
|
||||||
from src.task_endpoint import resolve_task_endpoint
|
from src.task_endpoint import resolve_task_endpoint, resolve_task_candidates
|
||||||
url, model, headers = resolve_task_endpoint(owner=user)
|
url, model, headers = resolve_task_endpoint(owner=user)
|
||||||
if not url:
|
if not url:
|
||||||
url, model, headers = _pick_endpoint_for_sort(owner=user)
|
url, model, headers = _pick_endpoint_for_sort(owner=user)
|
||||||
@@ -1493,9 +1493,12 @@ def setup_session_routes(
|
|||||||
# 16384 (was 4096): with many chats the folder JSON is large, and a
|
# 16384 (was 4096): with many chats the folder JSON is large, and a
|
||||||
# reasoning model spends tokens thinking first — 4096 truncated the
|
# reasoning model spends tokens thinking first — 4096 truncated the
|
||||||
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
|
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
|
||||||
raw = llm_call(url, model, [{"role": "user", "content": prompt}],
|
raw = llm_call_with_fallback(
|
||||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120,
|
resolve_task_candidates(fallback_url=url, fallback_model=model,
|
||||||
reasoning_effort=effort_for_call(url, model, user))
|
fallback_headers=headers, owner=user),
|
||||||
|
[{"role": "user", "content": prompt}],
|
||||||
|
temperature=0.3, max_tokens=16384, timeout=120,
|
||||||
|
reasoning_effort=effort_for_call(url, model, user))
|
||||||
logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}")
|
logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}")
|
||||||
# Extract JSON from response — handle markdown fences, leading text,
|
# Extract JSON from response — handle markdown fences, leading text,
|
||||||
# reasoning-model <think> blocks, and trailing commas.
|
# reasoning-model <think> blocks, and trailing commas.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from src.host_docker_access import (
|
|||||||
from src.optional_deps import prepare_optional_dependency_import
|
from src.optional_deps import prepare_optional_dependency_import
|
||||||
from src.auth_helpers import _auth_disabled
|
from src.auth_helpers import _auth_disabled
|
||||||
from src import process_lifecycle
|
from src import process_lifecycle
|
||||||
|
from src import shutdown as _shutdown
|
||||||
|
|
||||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
||||||
# on Windows, so importing them unconditionally crashed app startup there
|
# on Windows, so importing them unconditionally crashed app startup there
|
||||||
@@ -1025,7 +1026,7 @@ async def _generate_tmux(cmd: str, request: Request):
|
|||||||
lines_sent = 0
|
lines_sent = 0
|
||||||
exit_code = None
|
exit_code = None
|
||||||
|
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
# Check client disconnect
|
# Check client disconnect
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
# tmux keeps running — that's the whole point
|
# tmux keeps running — that's the whole point
|
||||||
@@ -1142,7 +1143,7 @@ async def _generate_win_detached(cmd: str, request: Request):
|
|||||||
|
|
||||||
lines_sent = 0
|
lines_sent = 0
|
||||||
exit_code = None
|
exit_code = None
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
|
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
|
||||||
return
|
return
|
||||||
@@ -1267,7 +1268,7 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"""Read chunks, split on \\n or \\r for progress bar support."""
|
"""Read chunks, split on \\n or \\r for progress bar support."""
|
||||||
try:
|
try:
|
||||||
buf = b""
|
buf = b""
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
chunk = await stream.read(4096)
|
chunk = await stream.read(4096)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
if buf:
|
if buf:
|
||||||
|
|||||||
@@ -914,6 +914,80 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
|
|||||||
"Overrides the source commit reported for runtime provenance, for builds "
|
"Overrides the source commit reported for runtime provenance, for builds "
|
||||||
"that ship without a git directory.",
|
"that ship without a git directory.",
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# -- Photo editor and image operations -----------------------------------
|
||||||
|
"ODYSSEUS_EDITOR_AGENT_BASES": (
|
||||||
|
"Model routing and providers", USER,
|
||||||
|
"Comma-separated base URLs for the image-editor agent backends; a single "
|
||||||
|
"base URL in the model endpoint config wins over this list.",
|
||||||
|
),
|
||||||
|
"ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS": (
|
||||||
|
"Model routing and providers", USER,
|
||||||
|
"Set to 1, true, yes or on to allow local image-generation and editing "
|
||||||
|
"backends to be reachable as image providers.",
|
||||||
|
),
|
||||||
|
"ODYSSEUS_IMAGE_OPS_BASE": (
|
||||||
|
"Model routing and providers", USER,
|
||||||
|
"Base URL of the image-operations backend (inpaint, outpainting and style "
|
||||||
|
"transfer); a trailing slash is stripped before use.",
|
||||||
|
),
|
||||||
|
"ODYSSEUS_IMAGE_EDIT_MAX_SIDE": (
|
||||||
|
"Upload and media limits", USER,
|
||||||
|
"Maximum pixel side (width or height) an edited image is downscaled to "
|
||||||
|
"before it is sent to the image backend.",
|
||||||
|
),
|
||||||
|
"ODYSSEUS_IMAGE_EDIT_MAX_STEPS": (
|
||||||
|
"Upload and media limits", USER,
|
||||||
|
"Maximum number of iterative edit steps the image backend may perform in "
|
||||||
|
"a single request.",
|
||||||
|
),
|
||||||
|
"ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT": (
|
||||||
|
"Upload and media limits", USER,
|
||||||
|
"Seconds the outpainting proxy waits for the image backend before giving "
|
||||||
|
"up on a single request.",
|
||||||
|
),
|
||||||
|
"ODYSSEUS_JEFF_": ("Agent loop and tool execution", INTERNAL, "Prefix for per-job decision adapter opt-ins; each job is disabled by default."),
|
||||||
|
"ODYSSEUS_JEFF_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Timeout in seconds for a decision adapter request."),
|
||||||
|
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
|
||||||
|
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
|
||||||
|
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
|
||||||
|
"ODYSSEUS_SPEC_DRIVER_TOPK": ("Agent loop and tool execution", INTERNAL, "Number of router-selected tool candidates exposed in a driver round."),
|
||||||
|
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
|
||||||
|
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
|
||||||
|
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
|
||||||
|
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
|
||||||
|
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
|
||||||
|
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
|
||||||
|
"ODYSSEUS_SPEC_SUBSET_OF_BASE": ("Agent loop and tool execution", INTERNAL, "Limit Spec tool selections to the existing request inventory by default."),
|
||||||
|
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
|
||||||
|
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
|
||||||
|
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
|
||||||
|
"ODYSSEUS_SPEC_THINK_ROUTER": ("Agent loop and tool execution", INTERNAL, "Enable router thinking decisions for explicitly configured Spec models."),
|
||||||
|
"ODYSSEUS_SPEC_TOOL_FORMAT": ("Agent loop and tool execution", INTERNAL, "Use names for compact router tool signatures, otherwise include descriptions."),
|
||||||
|
"ODYSSEUS_SPEC_TRIM_PROMPT": ("Agent loop and tool execution", INTERNAL, "Enable prompt trimming for the selected Spec tool menu."),
|
||||||
|
"ODYSSEUS_VISION_MAX_SIDE": ("Upload and media limits", USER, "Maximum pixel side for image attachments sent directly to a vision model."),
|
||||||
|
"ODYSSEUS_VISION_MODELS": ("Model routing and providers", USER, "Comma-separated model-name fragments that explicitly support inline images."),
|
||||||
|
"ODYSSEUS_VISION_PROBE": ("Model routing and providers", USER, "Allow a cached vision-capability probe against local model endpoints."),
|
||||||
|
"ODYSSEUS_SPEC_DIRECT_MODE": (
|
||||||
|
"Agent loop and tool execution", INTERNAL,
|
||||||
|
"Controls how the spec router answers high-confidence direct-answer turns; "
|
||||||
|
"either tool_choice_none (default) or empty_menu.",
|
||||||
|
),
|
||||||
|
"ODYSSEUS_SPEC_DIRECT_NOTOOLS": (
|
||||||
|
"Agent loop and tool execution", INTERNAL,
|
||||||
|
"Set to 1, true, yes or on to enable the spec router's direct-answer "
|
||||||
|
"no-tools path for high-confidence answer_directly decisions.",
|
||||||
|
),
|
||||||
|
"ODYSSEUS_SPEC_DIRECT_MIN_P": (
|
||||||
|
"Agent loop and tool execution", INTERNAL,
|
||||||
|
"Minimum confidence probability a spec-router direct-answer decision must "
|
||||||
|
"reach before the no-tools path is taken.",
|
||||||
|
),
|
||||||
|
"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT": (
|
||||||
|
"Deployment and first run", INTERNAL,
|
||||||
|
"Seconds the server waits for in-flight requests and background loops to "
|
||||||
|
"finish during a graceful shutdown.",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from src.memory import MemoryStoreUnreadable
|
from src.memory import MemoryStoreUnreadable
|
||||||
@@ -78,11 +79,13 @@ EXTRACT_SYSTEM_PROMPT = (
|
|||||||
"things the assistant said, one-off tasks, opinions on the current topic.\n\n"
|
"things the assistant said, one-off tasks, opinions on the current topic.\n\n"
|
||||||
"Rules:\n"
|
"Rules:\n"
|
||||||
"- MAX 2 facts per conversation — only the most important\n"
|
"- MAX 2 facts per conversation — only the most important\n"
|
||||||
"- Only extract facts the USER stated or clearly implied\n"
|
"- Only extract facts the USER explicitly stated about themselves\n"
|
||||||
|
"- Questions, requests to inspect records, and tool/assistant output are not evidence\n"
|
||||||
"- Each fact must be a single short sentence (under 15 words)\n"
|
"- Each fact must be a single short sentence (under 15 words)\n"
|
||||||
"- If a fact is similar to something likely already known, skip it\n"
|
"- If a fact is similar to something likely already known, skip it\n"
|
||||||
"- If nothing durable was revealed, return []\n\n"
|
"- If nothing durable was revealed, return []\n\n"
|
||||||
"Return a JSON array of objects with 'text' and 'category' fields.\n"
|
"Return a JSON array of objects with 'text', 'category', and 'evidence' fields. "
|
||||||
|
"Evidence must be an exact quote from a user's own factual statement.\n"
|
||||||
"Categories: 'identity', 'preference', 'fact', 'contact', 'project', 'goal'\n\n"
|
"Categories: 'identity', 'preference', 'fact', 'contact', 'project', 'goal'\n\n"
|
||||||
"Return ONLY valid JSON, no markdown fences."
|
"Return ONLY valid JSON, no markdown fences."
|
||||||
)
|
)
|
||||||
@@ -213,7 +216,10 @@ def _fallback_memory_candidates(messages) -> list[dict]:
|
|||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m = re.search(r"\bmy name is\s+([A-Za-z][A-Za-z0-9 .'\-]{1,50})\b", text, re.I)
|
m = re.search(
|
||||||
|
r"\bmy name is\s+([A-Za-z][A-Za-z0-9 '\-]{0,49}?)"
|
||||||
|
r"(?=\s+(?:and|but|please)\b|[.!?;,\n]|$)", text, re.I,
|
||||||
|
)
|
||||||
if m:
|
if m:
|
||||||
name = _clean_memory_value(m.group(1), 50)
|
name = _clean_memory_value(m.group(1), 50)
|
||||||
if name:
|
if name:
|
||||||
@@ -259,22 +265,183 @@ def _fallback_memory_candidates(messages) -> list[dict]:
|
|||||||
return candidates[:2]
|
return candidates[:2]
|
||||||
|
|
||||||
|
|
||||||
|
_USER_FACT_ASSERTION = re.compile(
|
||||||
|
r"\b(?:i(?:'m| am| live| work| use| have| prefer| like| love| hate| own| study| grew up)\b"
|
||||||
|
r"|my\s+[a-z][a-z -]{0,40}\s+(?:is|are|was|works|lives)\b"
|
||||||
|
r"|we\s+(?:are|live|work|have|own)\b"
|
||||||
|
r"|our\s+[a-z][a-z -]{0,40}\s+(?:is|are|was)\b)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_user_fact_evidence(fact: dict, messages) -> bool:
|
||||||
|
"""Require an exact user assertion before persisting an LLM-inferred memory."""
|
||||||
|
evidence = fact.get("evidence")
|
||||||
|
if not isinstance(evidence, str):
|
||||||
|
return False
|
||||||
|
evidence = re.sub(r"\s+", " ", evidence).strip()
|
||||||
|
if len(evidence.split()) < 3 or not _USER_FACT_ASSERTION.match(evidence):
|
||||||
|
return False
|
||||||
|
for message in messages:
|
||||||
|
if _message_role(message) != "user":
|
||||||
|
continue
|
||||||
|
utterance = re.sub(r"\s+", " ", _message_text(message)).strip()
|
||||||
|
position = utterance.casefold().find(evidence.casefold())
|
||||||
|
if position < 0:
|
||||||
|
continue
|
||||||
|
# Judge the clause containing the quote, so a later question does not
|
||||||
|
# erase an earlier factual statement in the same user turn.
|
||||||
|
clause_start = max(
|
||||||
|
utterance.rfind(mark, 0, position) for mark in (".", "?", "!", ";")
|
||||||
|
) + 1
|
||||||
|
clause_end = min(
|
||||||
|
[end for mark in (".", "?", "!", ";")
|
||||||
|
if (end := utterance.find(mark, position)) >= 0] or [len(utterance)]
|
||||||
|
)
|
||||||
|
clause = utterance[clause_start:clause_end].strip()
|
||||||
|
# A question about one's records is not a statement of the answer.
|
||||||
|
if utterance[clause_end:clause_end + 1] == "?" or re.match(
|
||||||
|
r"^(?:who|what|where|when|why|how|do|does|did|can|could|would|"
|
||||||
|
r"show|list|find|search|tell|summarize|open|read)\b",
|
||||||
|
clause, re.IGNORECASE,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||||
|
|
||||||
|
# Function words carry no distinguishing content. Dropping them before
|
||||||
|
# comparing two facts keeps punctuation and harmless rephrasing from defeating
|
||||||
|
# the duplicate check.
|
||||||
|
_STOPWORDS = frozenset(
|
||||||
|
"a an the and or but if then of to in on at for with by from as is are was "
|
||||||
|
"were be been being do does did has have had it its this that these those "
|
||||||
|
"there here user users someone somebody they them their he she his her him i "
|
||||||
|
"we you your my our me us who whom whose what when where why how also "
|
||||||
|
"currently now still about into over under again more most very can could "
|
||||||
|
"may might must will would should".split()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Words that frame a name/username statement; excluded when reading the value
|
||||||
|
# so the actual name tokens can be compared.
|
||||||
|
_NAME_TEMPLATE_WORDS = frozenset(
|
||||||
|
"name names named called call appears appear seems seem the user users his "
|
||||||
|
"her their it is are was were be been being username usernames login "
|
||||||
|
"account handle".split()
|
||||||
|
)
|
||||||
|
|
||||||
|
_NAME_KEYWORDS = frozenset({"name", "named", "called"})
|
||||||
|
_USERNAME_KEYWORDS = frozenset({"username", "login"})
|
||||||
|
|
||||||
|
|
||||||
|
def _content_tokens(text: str) -> set:
|
||||||
|
"""Lowercased content tokens with punctuation and stopwords removed."""
|
||||||
|
toks = _TOKEN_RE.findall((text or "").lower())
|
||||||
|
return {t for t in toks if t not in _STOPWORDS and len(t) >= 2}
|
||||||
|
|
||||||
|
|
||||||
|
def _name_slot(text: str):
|
||||||
|
"""Return (slot, value_tokens) when text states a name/username, else None.
|
||||||
|
|
||||||
|
Tokens are scanned so "username" is never mistaken for "name", and the
|
||||||
|
first keyword decides the slot (a sentence about a server *named* X that
|
||||||
|
also mentions a *username* Y is a 'name' fact, not a username fact)."""
|
||||||
|
words = _TOKEN_RE.findall((text or "").lower())
|
||||||
|
slot = None
|
||||||
|
for w in words:
|
||||||
|
if w in _NAME_KEYWORDS:
|
||||||
|
slot = "name"
|
||||||
|
break
|
||||||
|
if w in _USERNAME_KEYWORDS:
|
||||||
|
slot = "username"
|
||||||
|
break
|
||||||
|
if not slot:
|
||||||
|
return None
|
||||||
|
value = {
|
||||||
|
w for w in words
|
||||||
|
if w not in _NAME_TEMPLATE_WORDS and w not in _STOPWORDS and len(w) >= 2
|
||||||
|
}
|
||||||
|
return (slot, value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _name_slot_tokens(text: str):
|
||||||
|
slot = _name_slot(text)
|
||||||
|
return slot[1] if slot else None
|
||||||
|
|
||||||
|
|
||||||
|
def _name_vals_close(a: set, b: set) -> bool:
|
||||||
|
"""True when two name values state the same name: identical, or one is a
|
||||||
|
strict refinement of the other where the larger value is a clean name
|
||||||
|
(<=4 alphabetic tokens). Guards against merging a username into a fact
|
||||||
|
like 'a server named Odysseus ... with username pewds'."""
|
||||||
|
if a == b:
|
||||||
|
return True
|
||||||
|
if not a or not b:
|
||||||
|
return False
|
||||||
|
bigger = a if len(a) >= len(b) else b
|
||||||
|
clean = len(bigger) <= 4 and all(t.isalpha() for t in bigger)
|
||||||
|
return clean and (a <= b or b <= a)
|
||||||
|
|
||||||
|
|
||||||
def _is_text_duplicate(new_text: str, existing: list, threshold: float = 0.6) -> bool:
|
def _is_text_duplicate(new_text: str, existing: list, threshold: float = 0.6) -> bool:
|
||||||
"""Check if new_text is too similar to any existing memory (Jaccard similarity)."""
|
"""Check if new_text states the same fact as an existing memory.
|
||||||
new_tokens = set(new_text.lower().split())
|
|
||||||
|
Three tests, cheapest first: symmetric Jaccard over content tokens,
|
||||||
|
containment (one fact is a strict refinement of the other), and a
|
||||||
|
name/username-slot match. The naive whole-string comparison this replaces
|
||||||
|
kept punctuation (so "Alex." != "alex") and missed short-vs-full names
|
||||||
|
("Felix" vs "FELIX ARVID ULF KJELLBERG" scored 0.43).
|
||||||
|
"""
|
||||||
|
new_tokens = _content_tokens(new_text)
|
||||||
if not new_tokens:
|
if not new_tokens:
|
||||||
return False
|
return False
|
||||||
|
new_slot = _name_slot(new_text)
|
||||||
for entry in _memory_dicts(existing):
|
for entry in _memory_dicts(existing):
|
||||||
old_tokens = set(entry.get("text", "").lower().split())
|
old_text = entry.get("text", "")
|
||||||
|
old_tokens = _content_tokens(old_text)
|
||||||
if not old_tokens:
|
if not old_tokens:
|
||||||
continue
|
continue
|
||||||
intersection = new_tokens & old_tokens
|
intersection = new_tokens & old_tokens
|
||||||
|
if not intersection:
|
||||||
|
continue
|
||||||
union = new_tokens | old_tokens
|
union = new_tokens | old_tokens
|
||||||
if len(intersection) / len(union) >= threshold:
|
if len(intersection) / len(union) >= threshold:
|
||||||
return True
|
return True
|
||||||
|
# Containment: one fact is a strict refinement of the other. Guard on
|
||||||
|
# the shorter side so two-token facts ("Likes Python") are not merged
|
||||||
|
# into longer sentences that merely start the same way.
|
||||||
|
if len(intersection) >= 3 and len(intersection) / min(len(new_tokens), len(old_tokens)) >= 0.85:
|
||||||
|
return True
|
||||||
|
# Name slot: both state the same kind of name and the values match
|
||||||
|
# (short vs full name, or the same name behind different phrasing).
|
||||||
|
old_slot = _name_slot(old_text)
|
||||||
|
if new_slot and old_slot and new_slot[0] == old_slot[0]:
|
||||||
|
if _name_vals_close(new_slot[1], old_slot[1]):
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _name_enrichment_target(new_text: str, existing: list):
|
||||||
|
"""Return an existing entry whose name value is a strict subset of the
|
||||||
|
new fact's, so the fuller name replaces it in place; else None."""
|
||||||
|
ns = _name_slot(new_text)
|
||||||
|
if not ns:
|
||||||
|
return None
|
||||||
|
slot, new_val = ns
|
||||||
|
best = None
|
||||||
|
for entry in _memory_dicts(existing):
|
||||||
|
old_slot = _name_slot(entry.get("text", ""))
|
||||||
|
if not old_slot or old_slot[0] != slot:
|
||||||
|
continue
|
||||||
|
old_val = old_slot[1]
|
||||||
|
if old_val < new_val and _name_vals_close(old_val, new_val) and len(new_val) <= 4 and all(t.isalpha() for t in new_val):
|
||||||
|
if best is None or len(entry.get("text", "")) < len(best.get("text", "")):
|
||||||
|
best = entry
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
def _parse_extraction_json(raw: str) -> list:
|
def _parse_extraction_json(raw: str) -> list:
|
||||||
"""Parse the extraction LLM's reply into a list of facts, tolerating
|
"""Parse the extraction LLM's reply into a list of facts, tolerating
|
||||||
reasoning-model noise.
|
reasoning-model noise.
|
||||||
@@ -420,8 +587,13 @@ async def extract_and_store(
|
|||||||
if not isinstance(facts, list):
|
if not isinstance(facts, list):
|
||||||
facts = []
|
facts = []
|
||||||
|
|
||||||
|
# The extraction model sees assistant/tool text and can mistake a
|
||||||
|
# failed lookup or a one-off request for a durable personal fact.
|
||||||
|
# Only its claims with a quoted user assertion may enter memory.
|
||||||
|
facts = [fact for fact in facts if isinstance(fact, dict)
|
||||||
|
and _has_user_fact_evidence(fact, stripped_recent)]
|
||||||
if fallback_facts:
|
if fallback_facts:
|
||||||
facts = list(facts) + fallback_facts
|
facts.extend(fallback_facts)
|
||||||
|
|
||||||
if not facts:
|
if not facts:
|
||||||
logger.info("Auto memory extraction ran: 0 candidates")
|
logger.info("Auto memory extraction ran: 0 candidates")
|
||||||
@@ -483,6 +655,25 @@ async def extract_and_store(
|
|||||||
|
|
||||||
# Text dedup fallback: exact match + fuzzy similarity
|
# Text dedup fallback: exact match + fuzzy similarity
|
||||||
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
|
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
|
||||||
|
|
||||||
|
# Name enrichment: if an existing name fact is a strict prefix of
|
||||||
|
# this more complete one, upgrade it in place instead of adding a
|
||||||
|
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
|
||||||
|
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
|
||||||
|
_enrich = _name_enrichment_target(fact_text, user_existing)
|
||||||
|
if _enrich is not None:
|
||||||
|
_enrich["text"] = fact_text
|
||||||
|
_enrich["timestamp"] = int(time.time())
|
||||||
|
if category:
|
||||||
|
_enrich["category"] = category
|
||||||
|
if memory_vector and memory_vector.healthy:
|
||||||
|
try:
|
||||||
|
memory_vector.add(_enrich["id"], fact_text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Memory vector update failed for {_enrich['id']}: {e}")
|
||||||
|
added += 1
|
||||||
|
continue
|
||||||
|
|
||||||
if memory_manager.find_duplicates(fact_text, user_existing):
|
if memory_manager.find_duplicates(fact_text, user_existing):
|
||||||
continue
|
continue
|
||||||
# Fuzzy text similarity check (catches rephrased duplicates when vector index is unavailable)
|
# Fuzzy text similarity check (catches rephrased duplicates when vector index is unavailable)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""In-flight activity counters (heavy image ops) for safe restarts/observability.
|
||||||
|
|
||||||
|
A deploy restart kills any in-flight long image op (a diffusion edit is 60-120 s),
|
||||||
|
so the operator needs to know whether the process is busy before issuing
|
||||||
|
``systemctl --user restart``. These counters are process-local and cheap.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_image_ops = 0
|
||||||
|
_image_ops_by_name: Dict[str, int] = {}
|
||||||
|
_started_at = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def begin_image_op(name: str = "image") -> None:
|
||||||
|
global _image_ops
|
||||||
|
with _lock:
|
||||||
|
_image_ops += 1
|
||||||
|
_image_ops_by_name[name] = _image_ops_by_name.get(name, 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def end_image_op(name: str = "image") -> None:
|
||||||
|
global _image_ops
|
||||||
|
with _lock:
|
||||||
|
_image_ops = max(0, _image_ops - 1)
|
||||||
|
if name in _image_ops_by_name:
|
||||||
|
_image_ops_by_name[name] -= 1
|
||||||
|
if _image_ops_by_name[name] <= 0:
|
||||||
|
_image_ops_by_name.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def image_op(name: str = "image"):
|
||||||
|
begin_image_op(name)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
end_image_op(name)
|
||||||
|
|
||||||
|
|
||||||
|
def activity() -> Dict[str, object]:
|
||||||
|
with _lock:
|
||||||
|
return {
|
||||||
|
"image_ops": _image_ops,
|
||||||
|
"image_ops_by_name": dict(_image_ops_by_name),
|
||||||
|
"uptime_s": round(time.time() - _started_at, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def heavy_image_ops_active() -> int:
|
||||||
|
with _lock:
|
||||||
|
return _image_ops
|
||||||
|
|
||||||
|
|
||||||
|
def busy() -> bool:
|
||||||
|
return heavy_image_ops_active() > 0
|
||||||
@@ -240,11 +240,13 @@ def _artifact_match_is_callable(instruction: str, match: re.Match[str], path: st
|
|||||||
|
|
||||||
|
|
||||||
def _artifact_match_is_email_host(instruction: str, match: re.Match[str]) -> bool:
|
def _artifact_match_is_email_host(instruction: str, match: re.Match[str]) -> bool:
|
||||||
"""Reject the domain portion of an email address as an output path."""
|
"""Reject either dotted portion of an email address as an output path."""
|
||||||
|
|
||||||
start = match.start("path")
|
start = match.start("path")
|
||||||
prefix = instruction[max(0, start - 80):start]
|
prefix = instruction[max(0, start - 80):start]
|
||||||
return bool(re.search(r"[A-Za-z0-9_.+-]+@$", prefix))
|
return instruction[match.end("path"):].startswith("@") or bool(
|
||||||
|
re.search(r"[A-Za-z0-9_.+-]+@$", prefix)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _workspace_path_identity(value: str) -> str:
|
def _workspace_path_identity(value: str) -> str:
|
||||||
|
|||||||
+354
-17
@@ -162,7 +162,7 @@ def _resolved_tool_call_id(
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_MODEL_TOOL_SURFACES = {"none", "compact", "full"}
|
_MODEL_TOOL_SURFACES = {"none", "compact", "full", "spec"}
|
||||||
_ROUTE_THINKING_MODES = {"auto", "on", "off"}
|
_ROUTE_THINKING_MODES = {"auto", "on", "off"}
|
||||||
_NO_THINKING_COMPACT_DOMAINS = {
|
_NO_THINKING_COMPACT_DOMAINS = {
|
||||||
"email",
|
"email",
|
||||||
@@ -8472,7 +8472,7 @@ def _agent_route_tool_mode(
|
|||||||
return is_api_model, is_ollama_native, ollama_openai_compat
|
return is_api_model, is_ollama_native, ollama_openai_compat
|
||||||
|
|
||||||
|
|
||||||
def _configured_model_tool_surface(
|
def _configured_model_tool_surface_raw(
|
||||||
endpoint_url: str,
|
endpoint_url: str,
|
||||||
model: str,
|
model: str,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
@@ -8561,6 +8561,19 @@ _ADMIN_KEYWORDS = [
|
|||||||
"note", "notes", "todo", "todos", "reminder", "reminders",
|
"note", "notes", "todo", "todos", "reminder", "reminders",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_model_tool_surface(
|
||||||
|
endpoint_url: str,
|
||||||
|
model: str,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
headers: Optional[Dict] = None,
|
||||||
|
endpoint_id: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Tool surface as the harness sees it: Ajax Spec runs the compact runtime
|
||||||
|
(same dispatch, prompts, completions); only Spec hooks read the raw mode."""
|
||||||
|
value = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers, endpoint_id=endpoint_id)
|
||||||
|
return "compact" if value == "spec" else value
|
||||||
|
|
||||||
def _detect_admin_intent(messages: List[Dict]) -> bool:
|
def _detect_admin_intent(messages: List[Dict]) -> bool:
|
||||||
"""Check if the last user message suggests admin/management tool usage."""
|
"""Check if the last user message suggests admin/management tool usage."""
|
||||||
for msg in reversed(messages):
|
for msg in reversed(messages):
|
||||||
@@ -13565,6 +13578,20 @@ def _ody_qwen_terminal_tool_summary(tool_event: dict[str, Any], user_text: str =
|
|||||||
"""Return a deterministic user-facing answer for tools we can render safely."""
|
"""Return a deterministic user-facing answer for tools we can render safely."""
|
||||||
tool_name = _resolved_tool_event_name(tool_event)
|
tool_name = _resolved_tool_event_name(tool_event)
|
||||||
output = str(tool_event.get("output") or "")
|
output = str(tool_event.get("output") or "")
|
||||||
|
if tool_name in {
|
||||||
|
"mcp__email__list_email_accounts", "mcp__email__list_emails",
|
||||||
|
"mcp__email__search_emails", "mcp__email__read_email",
|
||||||
|
} and output.lstrip().startswith("{"):
|
||||||
|
# The external email bridge wraps stdout in a JSON result. Render its
|
||||||
|
# successful read payload, never the wrapper or a failed result.
|
||||||
|
try:
|
||||||
|
email_result = json.loads(output)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
email_result = None
|
||||||
|
if isinstance(email_result, dict) and isinstance(email_result.get("stdout"), str):
|
||||||
|
if email_result.get("exit_code") not in (None, 0) or email_result.get("error"):
|
||||||
|
return ""
|
||||||
|
output = email_result["stdout"]
|
||||||
command = str(tool_event.get("command") or "")
|
command = str(tool_event.get("command") or "")
|
||||||
action = ""
|
action = ""
|
||||||
try:
|
try:
|
||||||
@@ -21019,9 +21046,142 @@ def _blocks_before_inference(turn_contract) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_unavailable_reply(unavailable) -> str:
|
||||||
|
blocked = set(unavailable or ())
|
||||||
|
if blocked & (set(WEB_TOOL_NAMES) | {"private_browser", "youtube_tool"}):
|
||||||
|
return ("Web or browser access is unavailable for this chat. Enable Web search "
|
||||||
|
"in the chat controls, if available, and ask me to check again. "
|
||||||
|
"I have not opened the page or verified its contents.")
|
||||||
|
return ("The tool needed for this request is unavailable in this chat. "
|
||||||
|
"Enable the required capability, if available, and try again. "
|
||||||
|
"I have not performed the requested lookup or action.")
|
||||||
|
|
||||||
|
|
||||||
from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_authority, with_request_authority
|
from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_authority, with_request_authority
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||||
|
tool_policy=None):
|
||||||
|
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
|
||||||
|
router's picks (plus ask_user/update_plan), or the contract unchanged.
|
||||||
|
|
||||||
|
Runs before any runtime dispatch so the compact preview runtime and the
|
||||||
|
main loop both serve the router's set; only the offered tools change.
|
||||||
|
"""
|
||||||
|
if (turn_contract is None
|
||||||
|
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||||
|
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||||
|
return turn_contract
|
||||||
|
try:
|
||||||
|
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||||
|
return turn_contract
|
||||||
|
except Exception:
|
||||||
|
return turn_contract
|
||||||
|
try:
|
||||||
|
from src.spec_router import spec_route_tools as _spec_route_tools_early
|
||||||
|
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||||
|
disabled = set(disabled_tools or ())
|
||||||
|
picks, decided = _spec_route_tools_early(
|
||||||
|
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||||
|
from src.spec_router import LAST_DECISIONS as _spec_decisions
|
||||||
|
_spec_choice = (_spec_decisions[-1] if _spec_decisions else {}).get("choice")
|
||||||
|
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||||
|
and decided and _spec_choice == "answer_directly")
|
||||||
|
try:
|
||||||
|
_mgr = get_mcp_manager()
|
||||||
|
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
||||||
|
except Exception:
|
||||||
|
mcp_schemas = []
|
||||||
|
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
||||||
|
if _spec_choice == "ask_user":
|
||||||
|
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
||||||
|
# can act when the detail is there or ask when it is not.
|
||||||
|
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
||||||
|
if _top:
|
||||||
|
picks = set(picks) | {_top[0][0]}
|
||||||
|
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
||||||
|
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||||
|
base_offered = set(turn_contract.offered)
|
||||||
|
subset_of_base = os.getenv("ODYSSEUS_SPEC_SUBSET_OF_BASE", "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||||
|
# The router sees the full catalog, while this request's policy may
|
||||||
|
# deny a tool (for example, a browser turn with Web access switched
|
||||||
|
# off). Preserve that conflict as an unavailable contract. Keeping the
|
||||||
|
# old empty menu instead lets the model invent a tool result in prose.
|
||||||
|
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
||||||
|
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
||||||
|
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
||||||
|
_primary = f"mcp__email__{_primary}"
|
||||||
|
# `executable` belongs to the base contract. In subset=0, Spec may
|
||||||
|
# legitimately add a policy-permitted tool that the base inventory did
|
||||||
|
# not include. Only an explicit request-policy denial blocks the pick.
|
||||||
|
def _denied(name):
|
||||||
|
return (name in disabled
|
||||||
|
or (tool_policy is not None and (
|
||||||
|
tool_policy.blocks(name)
|
||||||
|
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
||||||
|
_unavailable_pick = ""
|
||||||
|
if _primary in picks and _denied(_primary):
|
||||||
|
_unavailable_pick = _primary
|
||||||
|
elif picks and all(_denied(name) for name in picks):
|
||||||
|
_unavailable_pick = sorted(picks)[0]
|
||||||
|
if _unavailable_pick:
|
||||||
|
blocked = replace(
|
||||||
|
turn_contract, required=frozenset(), offered=frozenset(),
|
||||||
|
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
||||||
|
required_read_operation=None, routing_experiment="spec-policy-denied",
|
||||||
|
)
|
||||||
|
_spec_active_contract.set(blocked)
|
||||||
|
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
||||||
|
return blocked
|
||||||
|
want = set(picks) - disabled
|
||||||
|
# Always-on basics only when the base turn offers them, so the Spec
|
||||||
|
# arm never offers a tool the base arm would not.
|
||||||
|
if not _spec_direct:
|
||||||
|
want |= {"ask_user", "update_plan"} & base_offered
|
||||||
|
if not (strict and decided):
|
||||||
|
want |= base_offered
|
||||||
|
if subset_of_base:
|
||||||
|
# A/B control: Spec offered set is a subset of the base offered set
|
||||||
|
# (measures narrowing only). Empty intersection keeps the base set.
|
||||||
|
want &= base_offered
|
||||||
|
if not want:
|
||||||
|
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
||||||
|
sorted(picks), sorted(base_offered))
|
||||||
|
return turn_contract
|
||||||
|
offer = want if strict else (want & set(turn_contract.executable))
|
||||||
|
by_name = {}
|
||||||
|
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
||||||
|
nm = (sc.get("function") or {}).get("name")
|
||||||
|
if nm in offer and nm not in by_name:
|
||||||
|
by_name[nm] = sc
|
||||||
|
for sc in turn_contract.schemas():
|
||||||
|
nm = (sc.get("function") or {}).get("name")
|
||||||
|
if nm in offer:
|
||||||
|
by_name[nm] = sc
|
||||||
|
if not by_name and not _spec_direct:
|
||||||
|
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
||||||
|
return turn_contract
|
||||||
|
# Keep the contract's required tools and required read whenever they
|
||||||
|
# are still offered: harness completion logic keys on them (e.g. a
|
||||||
|
# calendar list is a read, not an effect that may end with "Done.").
|
||||||
|
_rro = getattr(turn_contract, "required_read_operation", None)
|
||||||
|
_keep_rro = _rro if (_rro is not None and getattr(_rro, "tool", None) in by_name) else None
|
||||||
|
new = replace(
|
||||||
|
turn_contract, required=frozenset(n for n in (turn_contract.required or ()) if n in by_name),
|
||||||
|
offered=frozenset(by_name),
|
||||||
|
executable=frozenset(set(turn_contract.executable) | set(by_name)),
|
||||||
|
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
||||||
|
unavailable=(frozenset() if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" else turn_contract.unavailable), required_read_operation=_keep_rro, routing_experiment="spec-router")
|
||||||
|
_spec_active_contract.set(new)
|
||||||
|
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
||||||
|
sorted(by_name), sorted(turn_contract.offered), decided)
|
||||||
|
return new
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[spec-harness] router contract replacement failed open: %s", exc)
|
||||||
|
return turn_contract
|
||||||
|
|
||||||
|
|
||||||
@with_request_authority
|
@with_request_authority
|
||||||
@with_turn_contract
|
@with_turn_contract
|
||||||
@with_teacher_takeover
|
@with_teacher_takeover
|
||||||
@@ -21099,6 +21259,36 @@ async def stream_agent_loop(
|
|||||||
set(tool_policy.all_disabled_names()) if tool_policy else set()
|
set(tool_policy.all_disabled_names()) if tool_policy else set()
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Ajax Spec driver: the Spec router picks every step (src/spec_driver.py).
|
||||||
|
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
|
||||||
|
and not (tool_policy and tool_policy.block_all_tool_calls)
|
||||||
|
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
|
||||||
|
from src.spec_driver import stream_spec_turn
|
||||||
|
async for chunk in stream_spec_turn(
|
||||||
|
endpoint_url=endpoint_url, model=model, messages=messages, headers=headers,
|
||||||
|
session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
||||||
|
tool_policy=tool_policy, workspace=workspace, active_document=active_document,
|
||||||
|
client_runtime_context=client_runtime_context,
|
||||||
|
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||||
|
max_tokens=max(1024, min(int(max_tokens or 1024), 4096)), max_rounds=8,
|
||||||
|
temperature=0.0, request_authority=request_authority,
|
||||||
|
):
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
|
||||||
|
turn_contract = _spec_router_contract(
|
||||||
|
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||||
|
tool_policy=tool_policy)
|
||||||
|
if (turn_contract is not None
|
||||||
|
and getattr(turn_contract, "routing_experiment", "") == "spec-policy-denied"):
|
||||||
|
# A selected but disallowed tool is a policy outcome, not an invitation
|
||||||
|
# to generate an ungrounded answer with an empty tool menu.
|
||||||
|
reply = _spec_unavailable_reply(turn_contract.unavailable)
|
||||||
|
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||||
|
yield 'data: ' + json.dumps({"delta": reply}) + '\n\n'
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
|
return
|
||||||
|
|
||||||
if is_compact_preview_contract(turn_contract):
|
if is_compact_preview_contract(turn_contract):
|
||||||
from src.clean_agent_preview import stream_preview
|
from src.clean_agent_preview import stream_preview
|
||||||
async for chunk in stream_preview(
|
async for chunk in stream_preview(
|
||||||
@@ -21124,9 +21314,26 @@ async def stream_agent_loop(
|
|||||||
yield chunk
|
yield chunk
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_spec_turn = False
|
||||||
|
try:
|
||||||
|
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
||||||
|
except Exception:
|
||||||
|
_spec_turn = False
|
||||||
|
if _spec_turn and turn_contract is not None and getattr(turn_contract, "routing_experiment", "") != "spec-router":
|
||||||
|
# Ajax Spec: the router, not the regex turn contract, decides the tools.
|
||||||
|
# Drop the contract here and in the dispatcher's context so neither the
|
||||||
|
# route build nor execute_tool_block enforces its offered set.
|
||||||
|
logger.info("[spec-harness] dropping turn contract on spec turn: offered=%s unavailable=%s",
|
||||||
|
sorted(turn_contract.offered), sorted(turn_contract.unavailable))
|
||||||
|
turn_contract = None
|
||||||
|
try:
|
||||||
|
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||||
|
_spec_active_contract.set(None)
|
||||||
|
except Exception as _exc:
|
||||||
|
logger.warning("[spec-harness] could not clear active turn contract: %s", _exc)
|
||||||
if turn_contract is not None:
|
if turn_contract is not None:
|
||||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||||
if _blocks_before_inference(turn_contract):
|
if not _spec_turn and _blocks_before_inference(turn_contract):
|
||||||
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||||
clarification = (
|
clarification = (
|
||||||
"I can’t perform this request with the currently permitted tools "
|
"I can’t perform this request with the currently permitted tools "
|
||||||
@@ -24126,6 +24333,15 @@ async def stream_agent_loop(
|
|||||||
headers=candidate_headers,
|
headers=candidate_headers,
|
||||||
endpoint_id=(route_descriptor or {}).get("endpoint_id"),
|
endpoint_id=(route_descriptor or {}).get("endpoint_id"),
|
||||||
)
|
)
|
||||||
|
# "spec" = compact tools plus a small-model router; the harness-facing
|
||||||
|
# surface already reads "compact", so detect spec from the raw mode.
|
||||||
|
try:
|
||||||
|
_spec_surface = _configured_model_tool_surface_raw(
|
||||||
|
candidate_url, candidate_model, owner, headers=candidate_headers,
|
||||||
|
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
|
||||||
|
except Exception:
|
||||||
|
_spec_surface = False
|
||||||
|
_spec_offered = None
|
||||||
textual_tools = (
|
textual_tools = (
|
||||||
force_textual_tool_transport
|
force_textual_tool_transport
|
||||||
or force_textual_tools
|
or force_textual_tools
|
||||||
@@ -24159,6 +24375,57 @@ async def stream_agent_loop(
|
|||||||
_last_user,
|
_last_user,
|
||||||
_intent_domains,
|
_intent_domains,
|
||||||
)
|
)
|
||||||
|
if _spec_surface:
|
||||||
|
from src.spec_router import spec_route_tools as _spec_route_tools
|
||||||
|
|
||||||
|
if turn_contract is not None and getattr(turn_contract, "routing_experiment", "") == "spec-router":
|
||||||
|
_spec_added, _spec_decided = set(turn_contract.offered) - {"ask_user", "update_plan"}, True
|
||||||
|
else:
|
||||||
|
_spec_added, _spec_decided = _spec_route_tools(
|
||||||
|
_last_user,
|
||||||
|
FUNCTION_TOOL_SCHEMAS,
|
||||||
|
disabled_tools,
|
||||||
|
compacted_source,
|
||||||
|
return_decision=True,
|
||||||
|
)
|
||||||
|
_spec_added = _spec_added - set(disabled_tools)
|
||||||
|
# The router catalog uses the legacy native email names; the
|
||||||
|
# live tools are the MCP ones, and the legacy alias schemas are
|
||||||
|
# dropped when the MCP server is connected. Map picks over.
|
||||||
|
try:
|
||||||
|
from src.tool_utils import get_mcp_manager as _spec_mcp_mgr
|
||||||
|
_mgr = _spec_mcp_mgr()
|
||||||
|
_mcp_names = {
|
||||||
|
(schema.get("function") or {}).get("name")
|
||||||
|
for schema in (_mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else [])
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
_mcp_names = set()
|
||||||
|
_spec_added = {
|
||||||
|
(f"mcp__email__{name}" if f"mcp__email__{name}" in _mcp_names else name)
|
||||||
|
for name in _spec_added
|
||||||
|
}
|
||||||
|
_spec_mode = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower()
|
||||||
|
_compact_before = set(route_tools)
|
||||||
|
if _spec_mode == "strict" and _spec_decided:
|
||||||
|
# The router decides which tools are injected: its pick(s),
|
||||||
|
# tools already used in this chat, and the always-on basics.
|
||||||
|
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) - set(disabled_tools)
|
||||||
|
if _spec_strict:
|
||||||
|
route_tools = _spec_strict
|
||||||
|
_spec_offered = frozenset(route_tools)
|
||||||
|
else:
|
||||||
|
logger.info("[spec-harness] strict set empty after disabled-tool filter; keeping compact set")
|
||||||
|
route_tools = set(route_tools) | _spec_added
|
||||||
|
else:
|
||||||
|
route_tools = set(route_tools) | _spec_added
|
||||||
|
logger.info("[spec-debug] disabled_tools (%d): %s | policy block_all=%s disable_mcp=%s",
|
||||||
|
len(set(disabled_tools)), sorted(set(disabled_tools))[:80],
|
||||||
|
getattr(tool_policy, "block_all_tool_calls", None), getattr(tool_policy, "disable_mcp", None))
|
||||||
|
logger.info(
|
||||||
|
"[spec-harness] mode=%s decided=%s compact=%s final=%s",
|
||||||
|
_spec_mode, _spec_decided, sorted(_compact_before), sorted(route_tools),
|
||||||
|
)
|
||||||
# The compact router only receives user text, while native task
|
# The compact router only receives user text, while native task
|
||||||
# inputs may be declared out-of-band by the runner. Reapply that
|
# inputs may be declared out-of-band by the runner. Reapply that
|
||||||
# concrete input contract after compaction so an implicit video
|
# concrete input contract after compaction so an implicit video
|
||||||
@@ -24199,7 +24466,7 @@ async def stream_agent_loop(
|
|||||||
_irrelevant_local_media_web_tools.add("private_browser")
|
_irrelevant_local_media_web_tools.add("private_browser")
|
||||||
route_tools.difference_update(_irrelevant_local_media_web_tools)
|
route_tools.difference_update(_irrelevant_local_media_web_tools)
|
||||||
if turn_contract is not None and tool_surface != "full":
|
if turn_contract is not None and tool_surface != "full":
|
||||||
route_tools = set(turn_contract.offered)
|
route_tools = set(_spec_offered) if _spec_offered else set(turn_contract.offered)
|
||||||
if prompt_compact:
|
if prompt_compact:
|
||||||
route_tools.update(
|
route_tools.update(
|
||||||
_blocked_network_recovery_tools(tool_events)
|
_blocked_network_recovery_tools(tool_events)
|
||||||
@@ -24299,7 +24566,7 @@ async def stream_agent_loop(
|
|||||||
disabled_tools,
|
disabled_tools,
|
||||||
needs_admin=_needs_admin,
|
needs_admin=_needs_admin,
|
||||||
relevant_tools=prompt_route_tools,
|
relevant_tools=prompt_route_tools,
|
||||||
preserve_conversation=turn_contract is not None,
|
preserve_conversation=(turn_contract is not None) or _spec_surface,
|
||||||
mcp_disabled_map=_mcp_disabled_map,
|
mcp_disabled_map=_mcp_disabled_map,
|
||||||
compact=prompt_compact,
|
compact=prompt_compact,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -24511,6 +24778,13 @@ async def stream_agent_loop(
|
|||||||
_prepend_agent_directive(route_messages, build_active_plan_note(approved_plan))
|
_prepend_agent_directive(route_messages, build_active_plan_note(approved_plan))
|
||||||
if guide_only:
|
if guide_only:
|
||||||
_prepend_agent_directive(route_messages, GUIDE_ONLY_DIRECTIVE)
|
_prepend_agent_directive(route_messages, GUIDE_ONLY_DIRECTIVE)
|
||||||
|
if _spec_offered and os.getenv("ODYSSEUS_SPEC_TRIM_PROMPT", "1") != "0":
|
||||||
|
from src.spec_router import trim_system_prompt as _spec_trim
|
||||||
|
|
||||||
|
route_messages = _spec_trim(route_messages, _spec_offered)
|
||||||
|
from src.spec_router import drop_skills_index as _spec_drop_skills
|
||||||
|
|
||||||
|
route_messages = _spec_drop_skills(route_messages, _spec_offered)
|
||||||
return {
|
return {
|
||||||
"messages": route_messages,
|
"messages": route_messages,
|
||||||
"mcp_schemas": route_mcp_schemas,
|
"mcp_schemas": route_mcp_schemas,
|
||||||
@@ -24519,6 +24793,8 @@ async def stream_agent_loop(
|
|||||||
"is_ollama_native": is_native_ollama,
|
"is_ollama_native": is_native_ollama,
|
||||||
"ollama_openai_compat": is_ollama_compat,
|
"ollama_openai_compat": is_ollama_compat,
|
||||||
"tool_surface": tool_surface,
|
"tool_surface": tool_surface,
|
||||||
|
"spec_offered": _spec_offered,
|
||||||
|
"spec_surface": _spec_surface,
|
||||||
"ody_qwen_finetune_model": is_ody,
|
"ody_qwen_finetune_model": is_ody,
|
||||||
"qwen38_tool_router": _is_qwen38_tool_router(candidate_model) and not _full_inventory_mode,
|
"qwen38_tool_router": _is_qwen38_tool_router(candidate_model) and not _full_inventory_mode,
|
||||||
"ody_doc_finetune_mode": doc_mode,
|
"ody_doc_finetune_mode": doc_mode,
|
||||||
@@ -25076,6 +25352,24 @@ async def stream_agent_loop(
|
|||||||
if (schema.get("function", {}).get("name") or schema.get("name"))
|
if (schema.get("function", {}).get("name") or schema.get("name"))
|
||||||
not in disabled_tools
|
not in disabled_tools
|
||||||
]
|
]
|
||||||
|
_spec_names = route_state.get("spec_offered")
|
||||||
|
if _spec_names:
|
||||||
|
# Ajax Spec: the router, not the turn contract, decides which
|
||||||
|
# tools are injected for this turn.
|
||||||
|
contract_schemas = [
|
||||||
|
schema for schema in contract_schemas
|
||||||
|
if (schema.get("function", {}).get("name") or schema.get("name"))
|
||||||
|
in _spec_names
|
||||||
|
]
|
||||||
|
_have = {
|
||||||
|
(schema.get("function", {}).get("name") or schema.get("name"))
|
||||||
|
for schema in contract_schemas
|
||||||
|
}
|
||||||
|
contract_schemas += [
|
||||||
|
schema for schema in list(FUNCTION_TOOL_SCHEMAS) + list(route_mcp_schemas)
|
||||||
|
if schema.get("function", {}).get("name") in (set(_spec_names) - _have)
|
||||||
|
and schema.get("function", {}).get("name") not in disabled_tools
|
||||||
|
]
|
||||||
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
||||||
if route_state["is_api_model"]:
|
if route_state["is_api_model"]:
|
||||||
if tool_surface == "full":
|
if tool_surface == "full":
|
||||||
@@ -25137,15 +25431,18 @@ async def stream_agent_loop(
|
|||||||
and schema.get("name") not in disabled_tools
|
and schema.get("name") not in disabled_tools
|
||||||
]
|
]
|
||||||
if _pure_web_turn and tool_surface != "full":
|
if _pure_web_turn and tool_surface != "full":
|
||||||
allowed = _web_only_route_tools(_last_user, disabled_tools)
|
strict_spec = (route_state.get("spec_surface") and
|
||||||
schemas = [
|
os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict")
|
||||||
schema for schema in schemas
|
if not strict_spec:
|
||||||
if (
|
allowed = _web_only_route_tools(_last_user, disabled_tools)
|
||||||
schema.get("function", {}).get("name")
|
schemas = [
|
||||||
or schema.get("name")
|
schema for schema in schemas
|
||||||
) in allowed
|
if (
|
||||||
or schema.get("function", {}).get("name") in external_names
|
schema.get("function", {}).get("name")
|
||||||
]
|
or schema.get("name")
|
||||||
|
) in allowed
|
||||||
|
or schema.get("function", {}).get("name") in external_names
|
||||||
|
]
|
||||||
schemas = _drop_legacy_email_alias_schemas_when_mcp_available(schemas)
|
schemas = _drop_legacy_email_alias_schemas_when_mcp_available(schemas)
|
||||||
schemas = _apply_tool_surface_to_schemas(schemas, tool_surface)
|
schemas = _apply_tool_surface_to_schemas(schemas, tool_surface)
|
||||||
if (
|
if (
|
||||||
@@ -25997,6 +26294,8 @@ async def stream_agent_loop(
|
|||||||
"is_ollama_native": _is_ollama_native,
|
"is_ollama_native": _is_ollama_native,
|
||||||
"ollama_openai_compat": _ollama_openai_compat,
|
"ollama_openai_compat": _ollama_openai_compat,
|
||||||
"tool_surface": _route_state.get("tool_surface", ""),
|
"tool_surface": _route_state.get("tool_surface", ""),
|
||||||
|
"spec_offered": _route_state.get("spec_offered"),
|
||||||
|
"spec_surface": _route_state.get("spec_surface"),
|
||||||
"ody_qwen_finetune_model": _ody_qwen_finetune_model,
|
"ody_qwen_finetune_model": _ody_qwen_finetune_model,
|
||||||
"qwen38_tool_router": _qwen38_tool_router,
|
"qwen38_tool_router": _qwen38_tool_router,
|
||||||
"ody_doc_finetune_mode": _ody_doc_finetune_mode,
|
"ody_doc_finetune_mode": _ody_doc_finetune_mode,
|
||||||
@@ -26012,10 +26311,19 @@ async def stream_agent_loop(
|
|||||||
_active_route_state["request_messages"] = _initial_route_request_messages
|
_active_route_state["request_messages"] = _initial_route_request_messages
|
||||||
all_tool_schemas = _tool_schemas_for_route(_active_route_state)
|
all_tool_schemas = _tool_schemas_for_route(_active_route_state)
|
||||||
if turn_contract is not None:
|
if turn_contract is not None:
|
||||||
_relevant_tools = set(turn_contract.offered)
|
_relevant_tools = (
|
||||||
|
set(_route_state.get("spec_offered"))
|
||||||
|
if _route_state.get("spec_offered")
|
||||||
|
else set(turn_contract.offered)
|
||||||
|
)
|
||||||
agent_stream_timeout = int(get_setting("agent_stream_timeout_seconds", 300) or 300)
|
agent_stream_timeout = int(get_setting("agent_stream_timeout_seconds", 300) or 300)
|
||||||
|
|
||||||
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
||||||
|
if round_num == 1:
|
||||||
|
try:
|
||||||
|
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or "")), (m.get("metadata") or {}).get("source"), str(m.get("content") or "")[:40].replace("\n", " ")) for m in messages])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
|
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
|
||||||
_routing_intentional_exclusions = set(disabled_tools)
|
_routing_intentional_exclusions = set(disabled_tools)
|
||||||
if (
|
if (
|
||||||
@@ -27605,7 +27913,11 @@ async def stream_agent_loop(
|
|||||||
),
|
),
|
||||||
active_document=active_document,
|
active_document=active_document,
|
||||||
last_user=_last_user,
|
last_user=_last_user,
|
||||||
offered_tool_names=(set(turn_contract.offered) if turn_contract is not None else set(_tool_names_sent)),
|
offered_tool_names=(
|
||||||
|
set(turn_contract.offered)
|
||||||
|
if turn_contract is not None and not _active_route_state.get("spec_offered")
|
||||||
|
else set(_tool_names_sent)
|
||||||
|
),
|
||||||
recover_unoffered_tool_names=_recover_unoffered_local_tools,
|
recover_unoffered_tool_names=_recover_unoffered_local_tools,
|
||||||
passthrough_tool_names={
|
passthrough_tool_names={
|
||||||
schema["function"]["name"]
|
schema["function"]["name"]
|
||||||
@@ -32169,6 +32481,29 @@ async def stream_agent_loop(
|
|||||||
budget_hit = False
|
budget_hit = False
|
||||||
local_network_budget_hit = False
|
local_network_budget_hit = False
|
||||||
local_inspection_budget_hit = False
|
local_inspection_budget_hit = False
|
||||||
|
if _active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and tool_blocks and not used_native and _is_api_model:
|
||||||
|
# Ajax Spec with a native-API model: a round without a native call
|
||||||
|
# carries only harness-synthesized recovery blocks. Drop them all.
|
||||||
|
logger.info("[spec-harness] dropped %d synthesized tool block(s) (no native call): %s",
|
||||||
|
len(tool_blocks), [b.tool_type for b in tool_blocks])
|
||||||
|
tool_blocks = []; converted_calls = []
|
||||||
|
if (
|
||||||
|
_active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and used_native and tool_blocks
|
||||||
|
and len(converted_calls) == len(tool_blocks)
|
||||||
|
):
|
||||||
|
# Ajax Spec: only the model's own native calls run; harness-
|
||||||
|
# synthesized recovery blocks (no native call) are dropped. When
|
||||||
|
# converted_calls was reset (singleton collapse) keep every block.
|
||||||
|
_spec_keep = [
|
||||||
|
i for i in range(len(tool_blocks))
|
||||||
|
if i < len(converted_calls) and converted_calls[i] is not None
|
||||||
|
]
|
||||||
|
if len(_spec_keep) != len(tool_blocks):
|
||||||
|
logger.info("[spec-harness] dropped %d synthesized tool block(s): %s",
|
||||||
|
len(tool_blocks) - len(_spec_keep),
|
||||||
|
[tool_blocks[i].tool_type for i in range(len(tool_blocks)) if i not in _spec_keep])
|
||||||
|
tool_blocks = [tool_blocks[i] for i in _spec_keep]
|
||||||
|
converted_calls = [converted_calls[i] for i in _spec_keep]
|
||||||
for i, block in enumerate(tool_blocks):
|
for i, block in enumerate(tool_blocks):
|
||||||
native_call = converted_calls[i] if i < len(converted_calls) else None
|
native_call = converted_calls[i] if i < len(converted_calls) else None
|
||||||
tool_call_id = _resolved_tool_call_id(
|
tool_call_id = _resolved_tool_call_id(
|
||||||
@@ -33463,7 +33798,8 @@ async def stream_agent_loop(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
(_qwen38_tool_router or _full_inventory_mode) and (_pure_web_turn or _contextual_public_web_followup)
|
not (_active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and block.tool_type == "private_browser")
|
||||||
|
and (_qwen38_tool_router or _full_inventory_mode) and (_pure_web_turn or _contextual_public_web_followup)
|
||||||
and not _artifact_creation_requested
|
and not _artifact_creation_requested
|
||||||
and (block.tool_type == "web_search" or _web_execution_budget.searches)
|
and (block.tool_type == "web_search" or _web_execution_budget.searches)
|
||||||
and not _web_execution_budget.admit(
|
and not _web_execution_budget.admit(
|
||||||
@@ -33589,6 +33925,7 @@ async def stream_agent_loop(
|
|||||||
"mutation_epoch": _workspace_mutation_epoch,
|
"mutation_epoch": _workspace_mutation_epoch,
|
||||||
"error": _failure_text[:600],
|
"error": _failure_text[:600],
|
||||||
}
|
}
|
||||||
|
logger.info("[spec-debug] tool failure text: %s", _failure_text[:400])
|
||||||
logger.info(
|
logger.info(
|
||||||
"[agent] recorded failed call %s in round %s at mutation epoch %s",
|
"[agent] recorded failed call %s in round %s at mutation epoch %s",
|
||||||
_call_signature,
|
_call_signature,
|
||||||
|
|||||||
@@ -230,6 +230,18 @@ def classify(capture: DispatchCapture) -> dict[str, Any] | None:
|
|||||||
if capture.backend is not None:
|
if capture.backend is not None:
|
||||||
from src.agent_runtime.resources import ExternalResource
|
from src.agent_runtime.resources import ExternalResource
|
||||||
if isinstance(capture.backend.resource, ExternalResource):
|
if isinstance(capture.backend.resource, ExternalResource):
|
||||||
|
# A read through an external backend is still a read. The tool
|
||||||
|
# capability registry is sealed by the dispatcher, and unknown or
|
||||||
|
# mixed-effect calls continue to receive an external effect claim.
|
||||||
|
if all(bound is None for bound in (capture.browser, capture.filesystem,
|
||||||
|
capture.process, capture.owned)):
|
||||||
|
from src.tool_capabilities import ToolEffect, capabilities_for_action
|
||||||
|
capabilities = capabilities_for_action(capture.backend.transport_tool,
|
||||||
|
capture.backend.exact_input)
|
||||||
|
reads = frozenset({ToolEffect.READ_PUBLIC, ToolEffect.READ_WORKSPACE,
|
||||||
|
ToolEffect.READ_PRIVATE, ToolEffect.BROKERED_NETWORK_READ})
|
||||||
|
if capabilities.known and capabilities.effects and capabilities.effects <= reads:
|
||||||
|
return None
|
||||||
external = True
|
external = True
|
||||||
impact = (*impact, resource_ref(capture.backend.resource, "backend"))
|
impact = (*impact, resource_ref(capture.backend.resource, "backend"))
|
||||||
return {"impact_scope": impact, "dependencies": dependencies, "obligations": obligations, "external": external}
|
return {"impact_scope": impact, "dependencies": dependencies, "obligations": obligations, "external": external}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .interaction_tools import AskUserTool, UpdatePlanTool
|
|||||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||||
from .bg_job_tools import ManageBgJobsTool
|
from .bg_job_tools import ManageBgJobsTool
|
||||||
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
||||||
|
from .editor_tools import PlanEditTool, RunEditorCommandTool, GetEditorScreenshotTool, AiEditImageTool
|
||||||
from .admin_tools import (
|
from .admin_tools import (
|
||||||
ADMIN_TOOL_HANDLERS,
|
ADMIN_TOOL_HANDLERS,
|
||||||
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
|
||||||
@@ -72,6 +73,10 @@ TOOL_HANDLERS = {
|
|||||||
"list_sessions": ListSessionsTool().execute,
|
"list_sessions": ListSessionsTool().execute,
|
||||||
"send_to_session": SendToSessionTool().execute,
|
"send_to_session": SendToSessionTool().execute,
|
||||||
"manage_session": ManageSessionTool().execute,
|
"manage_session": ManageSessionTool().execute,
|
||||||
|
"plan_edit": PlanEditTool().execute,
|
||||||
|
"run_editor_command": RunEditorCommandTool().execute,
|
||||||
|
"get_editor_screenshot": GetEditorScreenshotTool().execute,
|
||||||
|
"ai_edit_image": AiEditImageTool().execute,
|
||||||
}
|
}
|
||||||
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
|
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
|
||||||
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
|
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
|
||||||
|
|||||||
@@ -0,0 +1,646 @@
|
|||||||
|
"""In-process agent tools for the scoped ``photo_editor`` capability family.
|
||||||
|
|
||||||
|
These handlers run as the logged-in owner and drive the image editor over the
|
||||||
|
owner-scoped in-process bridge (``/api/editor/bridge/*``) plus the app's
|
||||||
|
existing image transforms (``/api/image/*``). They are only offered to the
|
||||||
|
model when the ``photo_editor`` family is active (the Quick Edit popup or an
|
||||||
|
explicit photo-edit chat turn), never in the general tool list.
|
||||||
|
|
||||||
|
Loopback calls carry the internal token and impersonate ``owner`` (middleware
|
||||||
|
app.py), so the editor bridge resolves the same ``owner:session`` scope the
|
||||||
|
browser subscriber uses.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.tools._common import _INTERNAL_BASE, _internal_headers
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_message() -> str:
|
||||||
|
try:
|
||||||
|
from src.ai_interaction import IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||||
|
|
||||||
|
return IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
"Removing/adding objects needs an image generation provider; enable one "
|
||||||
|
"in Settings > Models."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_available(owner: Optional[str]) -> bool:
|
||||||
|
try:
|
||||||
|
from src.ai_interaction import image_editing_provider_available
|
||||||
|
|
||||||
|
return bool(image_editing_provider_available(owner))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Local diffusion/image servers can take 90-110s per image (8 steps) and
|
||||||
|
# longer on a cold start; the previous 120s default timed out mid-generation.
|
||||||
|
_EDITOR_TIMEOUT = 300.0
|
||||||
|
# A mask covering less than this fraction of the image cannot drive a useful
|
||||||
|
# regional inpaint (tiny figures, empty SAM output): fall back to grounding or
|
||||||
|
# whole-image instruction mode instead.
|
||||||
|
_MIN_MASK_FRACTION = 0.002
|
||||||
|
_AI_OPS = {
|
||||||
|
"remove-bg": "/api/image/remove-bg",
|
||||||
|
"upscale": "/api/image/upscale-local",
|
||||||
|
"denoise": "/api/image/denoise",
|
||||||
|
"enhance-face": "/api/image/enhance-face",
|
||||||
|
"inpaint": "/api/image/inpaint",
|
||||||
|
"generative": "/api/image/inpaint",
|
||||||
|
}
|
||||||
|
_PLACE_OP = "layers.placeImage"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(content: Any) -> Dict[str, Any]:
|
||||||
|
raw = content if isinstance(content, str) else json.dumps(content or {})
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw) if raw.strip() else {}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
parsed = {}
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
||||||
|
return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default")
|
||||||
|
|
||||||
|
|
||||||
|
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
|
||||||
|
headers = {**_internal_headers(owner), "Content-Type": "application/json"}
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.post(f"{_INTERNAL_BASE}{path}", json=payload, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return {"raw": resp.text}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_data_url(value: Any) -> Optional[str]:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
if value.startswith("data:"):
|
||||||
|
return value
|
||||||
|
if value.startswith("/"):
|
||||||
|
return None
|
||||||
|
return f"data:image/png;base64,{value}"
|
||||||
|
|
||||||
|
|
||||||
|
def _data_url_bytes(data_url: str) -> bytes:
|
||||||
|
_, _, b64 = data_url.partition(",")
|
||||||
|
return base64.b64decode(b64)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
|
||||||
|
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
|
||||||
|
from core.database import GalleryImage, SessionLocal
|
||||||
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
root = Path(GENERATED_IMAGES_DIR).resolve()
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
|
(root / filename).write_bytes(image_bytes)
|
||||||
|
new_id = str(uuid.uuid4())
|
||||||
|
width = height = 0
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with Image.open(io.BytesIO(image_bytes)) as img:
|
||||||
|
width, height = img.size
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
size = f"{width}x{height}" if width and height else ""
|
||||||
|
# The editor bridge uses an owner-scoped session id (e.g. "pewds:3")
|
||||||
|
# that is not a chat-sessions row. GalleryImage.session_id is a FK to
|
||||||
|
# sessions.id, so drop an unknown id instead of raising IntegrityError.
|
||||||
|
if session_id:
|
||||||
|
from core.database import Session as _ChatSession
|
||||||
|
|
||||||
|
try:
|
||||||
|
known = db.query(_ChatSession.id).filter(_ChatSession.id == session_id).first()
|
||||||
|
except Exception:
|
||||||
|
known = None
|
||||||
|
if known is None:
|
||||||
|
session_id = None
|
||||||
|
derived = GalleryImage(
|
||||||
|
id=new_id,
|
||||||
|
filename=filename,
|
||||||
|
prompt=prompt or None,
|
||||||
|
caption=None,
|
||||||
|
model=model,
|
||||||
|
size=size or None,
|
||||||
|
quality=None,
|
||||||
|
tags=None,
|
||||||
|
ai_tags=None,
|
||||||
|
session_id=session_id,
|
||||||
|
album_id=None,
|
||||||
|
owner=owner,
|
||||||
|
file_hash=hashlib.sha256(image_bytes).hexdigest(),
|
||||||
|
file_size=len(image_bytes),
|
||||||
|
width=width or None,
|
||||||
|
height=height or None,
|
||||||
|
)
|
||||||
|
db.add(derived)
|
||||||
|
db.commit()
|
||||||
|
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
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:
|
||||||
|
params: Dict[str, Any] = {
|
||||||
|
"dataUrl": data_url,
|
||||||
|
"name": name,
|
||||||
|
"masked": bool(masked),
|
||||||
|
"blend": blend or "normal",
|
||||||
|
"opacity": opacity,
|
||||||
|
}
|
||||||
|
# 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
|
||||||
|
# client keeps only the changed region) instead of the new layer overwriting
|
||||||
|
# the entire canvas. Omit the keys entirely when no mode is given so the
|
||||||
|
# client keeps its current behavior (e.g. generate_image must not diff-mask).
|
||||||
|
if mode:
|
||||||
|
params["mode"] = mode
|
||||||
|
params["differenceMask"] = mode == "instruction"
|
||||||
|
if mode == "instruction":
|
||||||
|
# Exclude a thin edge band from the client difference mask: full-frame
|
||||||
|
# regenerations can shift pixels along the border.
|
||||||
|
params["edgeExcludePx"] = 3
|
||||||
|
payload = {
|
||||||
|
"session": session,
|
||||||
|
"op": _PLACE_OP,
|
||||||
|
"params": params,
|
||||||
|
"wait": True,
|
||||||
|
"timeout": 60,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
|
||||||
|
return data.get("ok") is not False
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.warning("editor layer placement failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_selection_mask(owner: Optional[str], session: str) -> Optional[str]:
|
||||||
|
"""Return the editor's current selection as a data-URL mask, or None.
|
||||||
|
|
||||||
|
Generative ops (inpaint/generative) need a mask; the planner/judge may not
|
||||||
|
have supplied one, so fall back to whatever is selected in the editor.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"session": session,
|
||||||
|
"op": "selection.exportMask",
|
||||||
|
"params": {},
|
||||||
|
"wait": True,
|
||||||
|
"timeout": 30,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = await _post("/api/editor/bridge/command", payload, owner, timeout=45)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.warning("editor selection mask fetch failed: %s", exc)
|
||||||
|
return None
|
||||||
|
result = data.get("result") if isinstance(data, dict) else None
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return _as_data_url(result.get("mask"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_white_fraction(data_url: Optional[str]) -> float:
|
||||||
|
"""Fraction of a grayscale mask that is selected (white, >=128)."""
|
||||||
|
if not data_url:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
from PIL import Image as _PILImage
|
||||||
|
|
||||||
|
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))).convert("L") as im:
|
||||||
|
total = im.width * im.height
|
||||||
|
if not total:
|
||||||
|
return 0.0
|
||||||
|
hist = im.histogram()
|
||||||
|
return sum(hist[128:]) / total
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _image_is_blank(data_url: str) -> bool:
|
||||||
|
"""True when a result has an alpha channel that is essentially transparent."""
|
||||||
|
try:
|
||||||
|
from PIL import Image as _PILImage
|
||||||
|
|
||||||
|
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))) as im:
|
||||||
|
if im.mode not in ("RGBA", "LA") and "transparency" not in im.info:
|
||||||
|
return False
|
||||||
|
alpha = im.convert("RGBA").getchannel("A")
|
||||||
|
hist = alpha.histogram()
|
||||||
|
total = sum(hist)
|
||||||
|
return total > 0 and (sum(hist[1:]) / total) < 0.001
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _rect_mask_data_url(image_data_url: str, box: List[float], *, pad_frac: float = 0.15) -> Optional[str]:
|
||||||
|
"""Build a rectangular mask data URL from a [x1,y1,x2,y2] box, dilated.
|
||||||
|
|
||||||
|
Used when the grounding detector finds an object but SAM returns an empty
|
||||||
|
mask (tiny figures): a dilated box guarantees a usable region.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import Image as _PILImage, ImageDraw
|
||||||
|
|
||||||
|
with _PILImage.open(io.BytesIO(_data_url_bytes(image_data_url))) as im:
|
||||||
|
w, h = im.size
|
||||||
|
x1, y1, x2, y2 = (float(v) for v in box)
|
||||||
|
bw, bh = x2 - x1, y2 - y1
|
||||||
|
x1, y1 = max(0.0, x1 - bw * pad_frac), max(0.0, y1 - bh * pad_frac)
|
||||||
|
x2, y2 = min(float(w), x2 + bw * pad_frac), min(float(h), y2 + bh * pad_frac)
|
||||||
|
mask = _PILImage.new("L", (w, h), 0)
|
||||||
|
ImageDraw.Draw(mask).rectangle([x1, y1, x2, y2], fill=255)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
mask.save(buf, "PNG")
|
||||||
|
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _ground_box(owner: Optional[str], image_data_url: str, label: str) -> Optional[List[float]]:
|
||||||
|
"""Use the in-app grounding detector (OWL-ViT via /api/image/mask) for a box."""
|
||||||
|
try:
|
||||||
|
data = await _post("/api/image/mask", {"image": image_data_url, "text": label}, owner, timeout=90)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.warning("editor grounding box failed: %s", exc)
|
||||||
|
return None
|
||||||
|
box = data.get("bbox") if isinstance(data, dict) else None
|
||||||
|
if isinstance(box, (list, tuple)) and len(box) == 4:
|
||||||
|
try:
|
||||||
|
return [float(v) for v in box]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_keep_clause(text: str) -> str:
|
||||||
|
"""Append a keep-the-subject clause when the prompt lacks one."""
|
||||||
|
body = str(text or "").strip()
|
||||||
|
if not body:
|
||||||
|
return body
|
||||||
|
if re.search(r"\b(keep|preserve|leave|unchanged|identical|same)\b", body, re.I):
|
||||||
|
return body
|
||||||
|
return body.rstrip(". ") + ". Keep the main subject and everything else in the image exactly the same."
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_label(args: Dict[str, Any], prompt: str) -> str:
|
||||||
|
"""Best-effort object noun for grounding a regional removal."""
|
||||||
|
label = str(args.get("label") or args.get("object") or "").strip()
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
m = re.search(
|
||||||
|
r"\b(?:remove|delete|erase|eliminate|get\s+rid\s+of|take\s+out)\s+(?:the\s+|a\s+|an\s+)?"
|
||||||
|
r"([a-z][a-z \-]{1,30}?)(?:\s+(?:on|in|at|from|to|near|behind|beside|by)\b|[.,;!?]|$)",
|
||||||
|
prompt or "",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
noun = m.group(1).strip().lower()
|
||||||
|
if noun and noun not in {"background", "bg"}:
|
||||||
|
return noun
|
||||||
|
for noun in ("person", "people", "man", "woman", "car", "dog", "sign", "logo"):
|
||||||
|
if re.search(rf"\b{re.escape(noun)}\b", prompt or "", re.I):
|
||||||
|
return "person" if noun in {"person", "people", "man", "woman"} else noun
|
||||||
|
return "object"
|
||||||
|
|
||||||
|
|
||||||
|
class PlanEditTool:
|
||||||
|
"""Plan an image edit as ordered editor operations (no side effects)."""
|
||||||
|
|
||||||
|
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||||
|
args = _parse(content)
|
||||||
|
prompt = str(args.get("prompt") or "").strip()
|
||||||
|
if not prompt:
|
||||||
|
return "plan_edit: invalid", {"error": "plan_edit needs a non-empty `prompt`.", "exit_code": 1}
|
||||||
|
state = args.get("state") if isinstance(args.get("state"), dict) else {}
|
||||||
|
try:
|
||||||
|
data = await _post(
|
||||||
|
"/api/editor/plan",
|
||||||
|
{"prompt": prompt, "state": state, "mode": "plan", "session": _session(args, ctx)},
|
||||||
|
ctx.get("owner"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return "plan_edit: error", {"error": f"plan_edit failed: {exc}", "exit_code": 1}
|
||||||
|
steps = data.get("steps") or []
|
||||||
|
reply = str(data.get("reply") or "")
|
||||||
|
return f"plan_edit: {len(steps)} step(s)", {
|
||||||
|
"output": reply or f"Planned {len(steps)} editor step(s).",
|
||||||
|
"reply": reply,
|
||||||
|
"planner": data.get("planner"),
|
||||||
|
"steps": steps,
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RunEditorCommandTool:
|
||||||
|
"""Run a single validated editor op in the browser via the owner bridge."""
|
||||||
|
|
||||||
|
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||||
|
args = _parse(content)
|
||||||
|
op = str(args.get("op") or "").strip()
|
||||||
|
if not op:
|
||||||
|
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1}
|
||||||
|
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
||||||
|
payload = {
|
||||||
|
"session": _session(args, ctx),
|
||||||
|
"op": op,
|
||||||
|
"params": params,
|
||||||
|
"wait": bool(args.get("wait", True)),
|
||||||
|
"timeout": float(args.get("timeout") or 60),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
|
||||||
|
except Exception as exc:
|
||||||
|
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1}
|
||||||
|
ok = data.get("ok")
|
||||||
|
result = data.get("result")
|
||||||
|
summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
||||||
|
return f"run_editor_command: {op}", {
|
||||||
|
"output": summary[:4000],
|
||||||
|
"result": result,
|
||||||
|
"screenshot": data.get("screenshot"),
|
||||||
|
"ok": ok,
|
||||||
|
"exit_code": 0 if ok is not False else 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GetEditorScreenshotTool:
|
||||||
|
"""Capture the current editor canvas as a data URL."""
|
||||||
|
|
||||||
|
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||||
|
args = _parse(content)
|
||||||
|
payload = {
|
||||||
|
"session": _session(args, ctx),
|
||||||
|
"op": "screenshot",
|
||||||
|
"params": {},
|
||||||
|
"wait": True,
|
||||||
|
"timeout": float(args.get("timeout") or 30),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"), timeout=45)
|
||||||
|
except Exception as exc:
|
||||||
|
return "get_editor_screenshot: error", {"error": f"get_editor_screenshot failed: {exc}", "exit_code": 1}
|
||||||
|
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||||
|
shot = data.get("screenshot") or result.get("dataUrl")
|
||||||
|
if not shot:
|
||||||
|
return "get_editor_screenshot: unavailable", {
|
||||||
|
"error": "No editor screenshot was returned (is the editor open?).",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
return "get_editor_screenshot", {"output": "Captured the editor canvas.", "screenshot": shot, "exit_code": 0}
|
||||||
|
|
||||||
|
|
||||||
|
class AiEditImageTool:
|
||||||
|
"""Apply an AI image transform (remove-bg/upscale/denoise/enhance/inpaint)."""
|
||||||
|
|
||||||
|
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||||
|
args = _parse(content)
|
||||||
|
_t = {"start": time.perf_counter()}
|
||||||
|
op = str(args.get("op") or "").strip().lower()
|
||||||
|
if op not in _AI_OPS:
|
||||||
|
return "ai_edit_image: invalid", {
|
||||||
|
"error": f"ai_edit_image `op` must be one of {sorted(_AI_OPS)}.",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
owner = ctx.get("owner")
|
||||||
|
if op in ("inpaint", "generative") and not _provider_available(owner):
|
||||||
|
return "ai_edit_image: provider required", {
|
||||||
|
"error": _provider_message(),
|
||||||
|
"failure_kind": "image_provider_unavailable",
|
||||||
|
"needs_provider": True,
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
session = _session(args, ctx)
|
||||||
|
image = _as_data_url(args.get("image"))
|
||||||
|
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
|
||||||
|
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
|
||||||
|
resp.raise_for_status()
|
||||||
|
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
|
||||||
|
except Exception:
|
||||||
|
image = None
|
||||||
|
used_canvas = False
|
||||||
|
if not image:
|
||||||
|
shot = await GetEditorScreenshotTool().execute(json.dumps({"session": session}), ctx)
|
||||||
|
image = (shot[1] or {}).get("screenshot") if isinstance(shot, tuple) else None
|
||||||
|
used_canvas = bool(image)
|
||||||
|
if not image:
|
||||||
|
return "ai_edit_image: no image", {
|
||||||
|
"error": "ai_edit_image needs an `image` (data URL or same-origin URL) or an open editor.",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
_t["image"] = time.perf_counter()
|
||||||
|
payload: Dict[str, Any] = {"image": image}
|
||||||
|
model_choice = str(args.get("model") or "").strip()
|
||||||
|
mask_used = False
|
||||||
|
place_mode: Optional[str] = None
|
||||||
|
if op == "upscale":
|
||||||
|
payload["scale"] = int(args.get("scale") or 2)
|
||||||
|
elif op == "denoise":
|
||||||
|
payload["strength"] = float(args.get("strength") or 0.5)
|
||||||
|
elif op in ("inpaint", "generative"):
|
||||||
|
payload["prompt"] = str(args.get("prompt") or "").strip()
|
||||||
|
if model_choice:
|
||||||
|
# inpaint_proxy reads `_model` to route to a specific model.
|
||||||
|
payload["_model"] = model_choice
|
||||||
|
mode = str(args.get("mode") or "instruction").strip().lower()
|
||||||
|
_steps = int(args.get("steps") or 0)
|
||||||
|
if _steps > 0:
|
||||||
|
payload["steps"] = _steps
|
||||||
|
mask_used = False
|
||||||
|
mask = None
|
||||||
|
if mode == "masked":
|
||||||
|
mask = _as_data_url(args.get("mask"))
|
||||||
|
if not mask:
|
||||||
|
mask = await _fetch_selection_mask(owner, session)
|
||||||
|
# An empty/tiny SAM mask on small figures is useless: drop it.
|
||||||
|
if mask and _mask_white_fraction(mask) < _MIN_MASK_FRACTION:
|
||||||
|
mask = None
|
||||||
|
if not mask:
|
||||||
|
# Fall back to the grounding detector (OWL-ViT): a dilated
|
||||||
|
# box mask around the matching object.
|
||||||
|
box = await _ground_box(owner, image, _mask_label(args, payload["prompt"]))
|
||||||
|
if box:
|
||||||
|
candidate = _rect_mask_data_url(image, box, pad_frac=0.15)
|
||||||
|
if candidate and _mask_white_fraction(candidate) >= _MIN_MASK_FRACTION:
|
||||||
|
mask = candidate
|
||||||
|
if mask:
|
||||||
|
payload["mask"] = mask
|
||||||
|
payload["mode"] = "masked"
|
||||||
|
mask_used = True
|
||||||
|
else:
|
||||||
|
# Nothing usable to mask -> whole-image instruction fallback
|
||||||
|
# rather than placing a transparent/empty layer.
|
||||||
|
mode = "instruction"
|
||||||
|
if mode != "masked":
|
||||||
|
# Instruction mode: whole-image edit with no tight mask. Send a
|
||||||
|
# full-white mask so the edit endpoint accepts the request and
|
||||||
|
# regenerates the whole frame under the instruction prompt, which
|
||||||
|
# names what must stay unchanged. This avoids the mask-compositing
|
||||||
|
# halos that loose selections caused.
|
||||||
|
payload["mode"] = "instruction"
|
||||||
|
payload.pop("mask", None)
|
||||||
|
payload["prompt"] = _ensure_keep_clause(payload["prompt"])
|
||||||
|
try:
|
||||||
|
from PIL import Image as _PILImage
|
||||||
|
|
||||||
|
_raw = _data_url_bytes(image)
|
||||||
|
_w, _h = _PILImage.open(io.BytesIO(_raw)).size
|
||||||
|
_mbuf = io.BytesIO()
|
||||||
|
_PILImage.new("L", (_w, _h), 255).save(_mbuf, "PNG")
|
||||||
|
payload["mask"] = "data:image/png;base64," + base64.b64encode(_mbuf.getvalue()).decode()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
place_mode = "masked" if mask_used else "instruction"
|
||||||
|
_t["prep"] = time.perf_counter()
|
||||||
|
try:
|
||||||
|
data = await _post(_AI_OPS[op], payload, owner)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info(
|
||||||
|
"image-op ai_edit_image op=%s stage=provider FAILED image_ms=%.1f prep_ms=%.1f total_ms=%.1f err=%s",
|
||||||
|
op, (_t["image"] - _t["start"]) * 1000.0, (_t["prep"] - _t["image"]) * 1000.0,
|
||||||
|
(time.perf_counter() - _t["start"]) * 1000.0, exc,
|
||||||
|
)
|
||||||
|
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) failed: {exc}", "exit_code": 1}
|
||||||
|
_t["provider"] = time.perf_counter()
|
||||||
|
result = data.get("image") or data.get("data")
|
||||||
|
data_url = _as_data_url(result)
|
||||||
|
if not data_url:
|
||||||
|
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) returned no image.", "exit_code": 1}
|
||||||
|
if _image_is_blank(data_url):
|
||||||
|
return f"ai_edit_image: {op}", {
|
||||||
|
"error": f"ai_edit_image ({op}) produced an empty layer.",
|
||||||
|
"failure_kind": "empty_result",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
info: Dict[str, Any] = {
|
||||||
|
"output": f"Applied AI edit '{op}'" + (" to the current canvas." if used_canvas else "."),
|
||||||
|
"op": op,
|
||||||
|
"model": data.get("model") or model_choice or "",
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ai_edit_image persist failed: %s", exc)
|
||||||
|
_t["persist"] = time.perf_counter()
|
||||||
|
if bool(args.get("place", True)):
|
||||||
|
placed = await _place_layer(
|
||||||
|
data_url,
|
||||||
|
f"AI {op}",
|
||||||
|
owner,
|
||||||
|
session,
|
||||||
|
masked=mask_used,
|
||||||
|
blend=str(args.get("blend") or "normal"),
|
||||||
|
opacity=float(args.get("opacity") or 100),
|
||||||
|
mode=place_mode,
|
||||||
|
)
|
||||||
|
info["placed"] = placed
|
||||||
|
if placed:
|
||||||
|
info["output"] += " Placed the result as a new layer."
|
||||||
|
_t["place"] = time.perf_counter()
|
||||||
|
logger.info(
|
||||||
|
"image-op ai_edit_image op=%s mode=%s canvas=%s image_ms=%.1f mask_ms=%.1f "
|
||||||
|
"model_ms=%.1f persist_ms=%.1f place_ms=%.1f total_ms=%.1f",
|
||||||
|
op, place_mode or "instruction", used_canvas,
|
||||||
|
(_t["image"] - _t["start"]) * 1000.0,
|
||||||
|
(_t["prep"] - _t["image"]) * 1000.0,
|
||||||
|
(_t["provider"] - _t["prep"]) * 1000.0,
|
||||||
|
(_t["persist"] - _t["provider"]) * 1000.0,
|
||||||
|
(_t["place"] - _t["persist"]) * 1000.0,
|
||||||
|
(_t["place"] - _t["start"]) * 1000.0,
|
||||||
|
)
|
||||||
|
return f"ai_edit_image: {op}", info
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateImageLayerTool:
|
||||||
|
"""Generate an image with the configured image model and place it as a layer."""
|
||||||
|
|
||||||
|
async def execute(self, content: str, ctx: dict):
|
||||||
|
args = _parse(content)
|
||||||
|
prompt = str(args.get("prompt") or "").strip()
|
||||||
|
if not prompt:
|
||||||
|
return "generate_image: invalid", {"error": "generate_image needs a non-empty `prompt`.", "exit_code": 1}
|
||||||
|
owner = ctx.get("owner")
|
||||||
|
if not _provider_available(owner):
|
||||||
|
return "generate_image: provider required", {
|
||||||
|
"error": _provider_message(),
|
||||||
|
"failure_kind": "image_provider_unavailable",
|
||||||
|
"needs_provider": True,
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
session = _session(args, ctx)
|
||||||
|
payload: Dict[str, Any] = {"prompt": prompt}
|
||||||
|
for key in ("model", "size", "quality"):
|
||||||
|
if args.get(key):
|
||||||
|
payload[key] = args[key]
|
||||||
|
try:
|
||||||
|
from src.ai_interaction import do_generate_image
|
||||||
|
|
||||||
|
data = await do_generate_image(
|
||||||
|
json.dumps(payload, ensure_ascii=False),
|
||||||
|
session_id=session,
|
||||||
|
owner=owner,
|
||||||
|
incognito=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return "generate_image: error", {"error": f"generate_image failed: {exc}", "exit_code": 1}
|
||||||
|
result = data.get("image_url") or data.get("image") or data.get("data")
|
||||||
|
data_url = _as_data_url(result)
|
||||||
|
if not data_url:
|
||||||
|
return "generate_image", {"error": "generate_image returned no image.", "exit_code": 1}
|
||||||
|
info: Dict[str, Any] = {
|
||||||
|
"output": f"Generated an image for: {prompt[:120]}",
|
||||||
|
"image_size": data.get("image_size"),
|
||||||
|
"model": data.get("image_model") or str(args.get("model") or ""),
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("generate_image persist failed: %s", exc)
|
||||||
|
if bool(args.get("place", True)):
|
||||||
|
placed = await _place_layer(
|
||||||
|
data_url,
|
||||||
|
str(args.get("name") or "Generated image"),
|
||||||
|
owner,
|
||||||
|
session,
|
||||||
|
masked=bool(args.get("mask")),
|
||||||
|
blend=str(args.get("blend") or "normal"),
|
||||||
|
opacity=float(args.get("opacity") or 100),
|
||||||
|
)
|
||||||
|
info["placed"] = placed
|
||||||
|
if placed:
|
||||||
|
info["output"] += " Placed it as a new layer."
|
||||||
|
return "generate_image", info
|
||||||
File diff suppressed because it is too large
Load Diff
+72
-20
@@ -965,7 +965,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
|||||||
|
|
||||||
elif action == "open_panel":
|
elif action == "open_panel":
|
||||||
# Open a top-level panel/modal: documents/library, gallery,
|
# Open a top-level panel/modal: documents/library, gallery,
|
||||||
# calendar, email, sessions, notes, memories, skills, settings, theme, cookbook.
|
# calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook.
|
||||||
panel = parts[1].lower() if len(parts) > 1 else ""
|
panel = parts[1].lower() if len(parts) > 1 else ""
|
||||||
view = ""
|
view = ""
|
||||||
view_label = ""
|
view_label = ""
|
||||||
@@ -993,6 +993,8 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
|||||||
"note": "notes",
|
"note": "notes",
|
||||||
"todo": "notes",
|
"todo": "notes",
|
||||||
"todos": "notes",
|
"todos": "notes",
|
||||||
|
"tasks": "tasks",
|
||||||
|
"task": "tasks",
|
||||||
"memories": "memories",
|
"memories": "memories",
|
||||||
"memory": "memories",
|
"memory": "memories",
|
||||||
"brain": "memories",
|
"brain": "memories",
|
||||||
@@ -1010,7 +1012,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
|||||||
}
|
}
|
||||||
target = _panel_aliases.get(panel)
|
target = _panel_aliases.get(panel)
|
||||||
if not target:
|
if not target:
|
||||||
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, memories, skills, settings, theme, cookbook."}
|
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook."}
|
||||||
if target == "cookbook":
|
if target == "cookbook":
|
||||||
cookbook_views = {
|
cookbook_views = {
|
||||||
"models": ("Search", "models"), "model": ("Search", "models"),
|
"models": ("Search", "models"), "model": ("Search", "models"),
|
||||||
@@ -1155,7 +1157,50 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
|||||||
# Image generation
|
# Image generation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
# Message used whenever a generative image edit (object add/remove/replace,
|
||||||
|
# image generation) is requested but no image provider is configured. The
|
||||||
|
# photo-editor agent and the image tools surface this verbatim so the user gets
|
||||||
|
# a plain, actionable explanation instead of a silent substitute edit.
|
||||||
|
IMAGE_PROVIDER_REQUIRED_MESSAGE = (
|
||||||
|
"Removing/adding objects needs an image generation provider; enable one in "
|
||||||
|
"Settings > Models."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||||
|
"""True when an enabled, owner-visible image endpoint can serve AI edits.
|
||||||
|
|
||||||
|
Generative operations (object add/remove/replace, image generation) require
|
||||||
|
a configured image model. Local transforms (remove-bg, upscale, denoise,
|
||||||
|
enhance-face) do not, but the photo-editor agent gates the whole generative
|
||||||
|
request on this so it never falls back to deterministic approximations.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.settings import load_settings
|
||||||
|
|
||||||
|
if not load_settings().get("image_gen_enabled", True):
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from src.database import SessionLocal, ModelEndpoint
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
q = db.query(ModelEndpoint).filter(
|
||||||
|
ModelEndpoint.is_enabled == True, # noqa: E712
|
||||||
|
ModelEndpoint.model_type == "image",
|
||||||
|
)
|
||||||
|
q = owner_filter(q, ModelEndpoint, owner)
|
||||||
|
return q.first() is not None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
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).
|
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||||
|
|
||||||
Content format:
|
Content format:
|
||||||
@@ -1255,6 +1300,14 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
|
|
||||||
async def _resolve_image_model(model_name: str):
|
async def _resolve_image_model(model_name: str):
|
||||||
def _call():
|
def _call():
|
||||||
|
# Try a plain by-name lookup first. Gateway endpoints (OpenRouter)
|
||||||
|
# register as `llm` yet serve image models, and the image-typed
|
||||||
|
# query's last-resort fallback would otherwise hijack a cloud model
|
||||||
|
# onto the local image endpoint (gpt-5-image -> qwen-image-edit).
|
||||||
|
try:
|
||||||
|
return _resolve_model(model_name, owner=owner)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
return _resolve_model(model_name, owner=owner, model_type="image")
|
return _resolve_model(model_name, owner=owner, model_type="image")
|
||||||
except TypeError as exc:
|
except TypeError as exc:
|
||||||
@@ -1314,7 +1367,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
else:
|
else:
|
||||||
payload["quality"] = "medium"
|
payload["quality"] = "medium"
|
||||||
|
|
||||||
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={prompt[:80]}")
|
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={'[private]' if incognito else prompt[:80]}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# GPT image models can take 30-120s+ depending on quality
|
# GPT image models can take 30-120s+ depending on quality
|
||||||
@@ -1365,18 +1418,22 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
logger.warning(f"Failed to save gallery record: {_ge}")
|
logger.warning(f"Failed to save gallery record: {_ge}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# GPT image models always return b64_json; DALL-E may return url
|
def _store_image(image_bytes):
|
||||||
if img.get("b64_json"):
|
if incognito:
|
||||||
|
return 'data:image/png;base64,' + base64.b64encode(image_bytes).decode('ascii'), None
|
||||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
img_path = img_dir / filename
|
img_path = img_dir / filename
|
||||||
img_path.write_bytes(base64.b64decode(img.get("b64_json")))
|
img_path.write_bytes(image_bytes)
|
||||||
if owner:
|
if owner:
|
||||||
from src.generated_images import register_generated_image
|
from src.generated_images import register_generated_image
|
||||||
register_generated_image(filename, owner)
|
register_generated_image(filename, owner)
|
||||||
image_url = f"/api/generated-image/{filename}"
|
return f"/api/generated-image/{filename}", _save_to_gallery(filename)
|
||||||
image_id = _save_to_gallery(filename)
|
|
||||||
|
# Private results stay in the response, never in the gallery or on disk.
|
||||||
|
if img.get("b64_json"):
|
||||||
|
image_url, image_id = _store_image(base64.b64decode(img['b64_json']))
|
||||||
|
|
||||||
elif img.get("url"):
|
elif img.get("url"):
|
||||||
# Download external URL and save locally (DALL-E returns temp URLs)
|
# Download external URL and save locally (DALL-E returns temp URLs)
|
||||||
@@ -1390,16 +1447,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
try:
|
try:
|
||||||
dl_resp = httpx.get(result_url, timeout=60)
|
dl_resp = httpx.get(result_url, timeout=60)
|
||||||
if dl_resp.status_code == 200:
|
if dl_resp.status_code == 200:
|
||||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
image_url, image_id = _store_image(dl_resp.content)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
|
||||||
img_path = img_dir / filename
|
|
||||||
img_path.write_bytes(dl_resp.content)
|
|
||||||
if owner:
|
|
||||||
from src.generated_images import register_generated_image
|
|
||||||
register_generated_image(filename, owner)
|
|
||||||
image_url = f"/api/generated-image/{filename}"
|
|
||||||
image_id = _save_to_gallery(filename)
|
|
||||||
else:
|
else:
|
||||||
image_url = result_url # fallback to external URL
|
image_url = result_url # fallback to external URL
|
||||||
except Exception as _dl_e:
|
except Exception as _dl_e:
|
||||||
@@ -1436,6 +1484,7 @@ async def do_edit_image(
|
|||||||
size: str = "auto",
|
size: str = "auto",
|
||||||
quality: str = "medium",
|
quality: str = "medium",
|
||||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||||
|
incognito: bool = False,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Edit an uploaded image using the configured image endpoint."""
|
"""Edit an uploaded image using the configured image endpoint."""
|
||||||
import base64
|
import base64
|
||||||
@@ -1509,7 +1558,7 @@ async def do_edit_image(
|
|||||||
request_id = uuid.uuid4().hex
|
request_id = uuid.uuid4().hex
|
||||||
payload["request_id"] = request_id
|
payload["request_id"] = request_id
|
||||||
|
|
||||||
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80])
|
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, '[private]' if incognito else path.name, '[private]' if incognito else prompt[:80])
|
||||||
|
|
||||||
def _save_edited_image_to_gallery(filename: str) -> str:
|
def _save_edited_image_to_gallery(filename: str) -> str:
|
||||||
try:
|
try:
|
||||||
@@ -1539,6 +1588,9 @@ async def do_edit_image(
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
with Image.open(BytesIO(image_bytes)) as output:
|
with Image.open(BytesIO(image_bytes)) as output:
|
||||||
size = f"{output.width}x{output.height}"
|
size = f"{output.width}x{output.height}"
|
||||||
|
image_mime = Image.MIME.get(output.format, 'image/png')
|
||||||
|
if incognito:
|
||||||
|
return 'data:' + image_mime + ';base64,' + base64.b64encode(image_bytes).decode('ascii'), ''
|
||||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||||
|
|||||||
@@ -2465,6 +2465,100 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
return str(e), False
|
return str(e), False
|
||||||
|
|
||||||
|
|
||||||
|
async def _spec_triage_email_urgency(item: dict, *, triage_version: int) -> dict | None:
|
||||||
|
"""Classify one email's urgency with the trained jeff ``triage`` adapter.
|
||||||
|
|
||||||
|
Returns an email-urgency verdict (same shape as the LLM path) or None when
|
||||||
|
the per-job flag is off or the adapter is unavailable, so the caller falls
|
||||||
|
through to the LLM candidate chain. Only the ``urgency`` question is sent:
|
||||||
|
jeff scores each question in its own forward pass, and that is the question
|
||||||
|
this adapter was trained on.
|
||||||
|
"""
|
||||||
|
from src.jeff_decision import jeff_job_enabled, jeff_systemone, score_value
|
||||||
|
|
||||||
|
if not jeff_job_enabled("EMAIL_URGENCY"):
|
||||||
|
return None
|
||||||
|
message = f"Subject: {item.get('subject','')}\n\n{item.get('body','')}".strip()
|
||||||
|
if not message:
|
||||||
|
return None
|
||||||
|
answers = await jeff_systemone(
|
||||||
|
"triage",
|
||||||
|
{"channel": "email", "message": message[:8000]},
|
||||||
|
{"urgency": {
|
||||||
|
"type": "score",
|
||||||
|
"instructions": "How urgently does this need a response?",
|
||||||
|
"criteria": [
|
||||||
|
"No action needed",
|
||||||
|
"Can wait a few days",
|
||||||
|
"Handle within a day",
|
||||||
|
"Handle within hours",
|
||||||
|
"Handle immediately",
|
||||||
|
],
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
if not answers:
|
||||||
|
return None
|
||||||
|
exp = score_value(answers.get("urgency"))
|
||||||
|
if exp is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Adapter's 0-4 urgency scale -> the app's 0-3 score.
|
||||||
|
score = max(0, min(3, int(round(exp))))
|
||||||
|
reason = "triage adapter"
|
||||||
|
|
||||||
|
# Same high-precision correction the prompt path applies: a person waiting
|
||||||
|
# in person is always urgent, whatever the model thinks.
|
||||||
|
blob = f"{item.get('headers','')}\n{item.get('subject','')}\n{item.get('body','')}".lower()
|
||||||
|
if re.search(r"\b(i'?m|i am|im|we'?re|we are)\s+outside\b", blob) or re.search(
|
||||||
|
r"\b(waiting outside|at the door|locked out|can'?t get in|cannot get in)\b", blob
|
||||||
|
):
|
||||||
|
if score < 3:
|
||||||
|
reason = "person is waiting outside"
|
||||||
|
score = 3
|
||||||
|
|
||||||
|
# Same bulk/marketing guard the prompt path applies: a newsletter or
|
||||||
|
# promotion is not urgent no matter how time-limited it sounds.
|
||||||
|
bulkish = bool(re.search(
|
||||||
|
r"\b(list-unsubscribe|list-id|mailchimp|mailchimpapp|view this email in your browser|unsubscribe|newsletter|digest|precedence:\s*bulk)\b",
|
||||||
|
blob,
|
||||||
|
))
|
||||||
|
# The adapter was trained on support tickets, so it over-rates promotions.
|
||||||
|
# Require a clear bulk header or several marketing terms before zeroing.
|
||||||
|
marketing_hits = sum(1 for t in (
|
||||||
|
"advertisement", "sponsored", "promo", "promotion", "sale", "discount",
|
||||||
|
"offer", "limited time", "deal", "coupon", "shop now", "buy now",
|
||||||
|
"sold out", "low tickets",
|
||||||
|
) if t in blob)
|
||||||
|
if bulkish or marketing_hits >= 2:
|
||||||
|
score = 0
|
||||||
|
reason = "bulk mail; no personal reply needed"
|
||||||
|
|
||||||
|
tags = []
|
||||||
|
if score >= 3:
|
||||||
|
tags.append("urgent")
|
||||||
|
elif score >= 2:
|
||||||
|
tags.append("reply-soon")
|
||||||
|
|
||||||
|
_from_raw = item.get("from", "") or ""
|
||||||
|
if "<" in _from_raw:
|
||||||
|
_from_short = _from_raw.split("<", 1)[0].strip().strip('"') or _from_raw
|
||||||
|
else:
|
||||||
|
_from_short = _from_raw
|
||||||
|
|
||||||
|
return {
|
||||||
|
"score": score,
|
||||||
|
"tags": tags[:4],
|
||||||
|
"spam": False,
|
||||||
|
"reason": reason,
|
||||||
|
"subject": (item.get("subject") or "")[:200],
|
||||||
|
"from": _from_short[:120],
|
||||||
|
"triage_version": triage_version,
|
||||||
|
"message_id": (item.get("message_id") or "").strip(),
|
||||||
|
"unread": bool(item.get("unread")),
|
||||||
|
"ts": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||||
"""Scan unread emails across all accounts, LLM-triage new ones, cache
|
"""Scan unread emails across all accounts, LLM-triage new ones, cache
|
||||||
per-UID verdicts, tag the inbox, and fire a reminder when a previously
|
per-UID verdicts, tag the inbox, and fire a reminder when a previously
|
||||||
@@ -2852,6 +2946,16 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
per_uid_scores[key] = verdict
|
per_uid_scores[key] = verdict
|
||||||
saved_classifications += 1
|
saved_classifications += 1
|
||||||
continue
|
continue
|
||||||
|
# ── Spec decision adapter (triage): a calibrated urgency score
|
||||||
|
# with no generated-text prompt. Enabled per-job via
|
||||||
|
# ODYSSEUS_JEFF_EMAIL_URGENCY; any failure returns None and the
|
||||||
|
# LLM candidate chain below still runs.
|
||||||
|
_tri = await _spec_triage_email_urgency(item, triage_version=TRIAGE_VERSION)
|
||||||
|
if _tri is not None:
|
||||||
|
cache.setdefault("uids", {})[item["uid"]] = _tri
|
||||||
|
per_uid_scores[key] = _tri
|
||||||
|
saved_classifications += 1
|
||||||
|
continue
|
||||||
# ── LLM-classify. JSON-only response; bullet-proof parse.
|
# ── LLM-classify. JSON-only response; bullet-proof parse.
|
||||||
llm_attempts += 1
|
llm_attempts += 1
|
||||||
prompt = (
|
prompt = (
|
||||||
|
|||||||
+90
-2
@@ -81,6 +81,16 @@ def is_vision_model(model_name: str) -> bool:
|
|||||||
drops the image entirely. See issue #124.
|
drops the image entirely. See issue #124.
|
||||||
"""
|
"""
|
||||||
m = (model_name or "").lower()
|
m = (model_name or "").lower()
|
||||||
|
if not m:
|
||||||
|
return False
|
||||||
|
# Per-model override (comma-separated substrings), e.g. a local model whose
|
||||||
|
# name carries no vision/VL hint but answers images natively.
|
||||||
|
override = os.getenv("ODYSSEUS_VISION_MODELS", "")
|
||||||
|
if override:
|
||||||
|
for token in override.split(","):
|
||||||
|
token = token.strip().lower()
|
||||||
|
if token and token in m:
|
||||||
|
return True
|
||||||
if any(kw in m for kw in _VISION_MODEL_KEYWORDS):
|
if any(kw in m for kw in _VISION_MODEL_KEYWORDS):
|
||||||
return True
|
return True
|
||||||
return bool(_VISION_VL_RE.search(m))
|
return bool(_VISION_VL_RE.search(m))
|
||||||
@@ -164,8 +174,10 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
|||||||
|
|
||||||
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
||||||
"""Whether a model accepts images, using the endpoint's reported
|
"""Whether a model accepts images, using the endpoint's reported
|
||||||
capability when available (LM Studio) and falling back to name-based
|
capability when available (LM Studio), a cached one-time probe for local
|
||||||
detection otherwise."""
|
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
||||||
|
if is_vision_model(model_name):
|
||||||
|
return True
|
||||||
if endpoint_url:
|
if endpoint_url:
|
||||||
try:
|
try:
|
||||||
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
|
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
|
||||||
@@ -173,9 +185,85 @@ def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
|||||||
advertised = None
|
advertised = None
|
||||||
if advertised is not None:
|
if advertised is not None:
|
||||||
return advertised
|
return advertised
|
||||||
|
try:
|
||||||
|
probed = probe_endpoint_supports_vision(endpoint_url, model_name or "")
|
||||||
|
except Exception:
|
||||||
|
probed = None
|
||||||
|
if probed is not None:
|
||||||
|
return probed
|
||||||
return is_vision_model(model_name)
|
return is_vision_model(model_name)
|
||||||
|
|
||||||
|
|
||||||
|
# 1x1 red PNG — the cheapest possible image probe payload.
|
||||||
|
_PROBE_IMAGE_DATA_URL = (
|
||||||
|
"data:image/png;base64,"
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||||
|
)
|
||||||
|
_VISION_PROBE_TTL = 3600.0
|
||||||
|
# (host, model) -> (bool | None, expiry); None = undecidable (auth/network).
|
||||||
|
_vision_probe_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _vision_probe_enabled() -> bool:
|
||||||
|
return os.getenv("ODYSSEUS_VISION_PROBE", "1").strip().lower() not in ("0", "false", "off", "no")
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_completions_url(endpoint_url: str) -> str:
|
||||||
|
base = (endpoint_url or "").rstrip("/")
|
||||||
|
if base.endswith("/chat/completions"):
|
||||||
|
return base
|
||||||
|
return base + "/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Optional[bool]:
|
||||||
|
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
||||||
|
an image_url content part for `model_name`?
|
||||||
|
|
||||||
|
Returns True on a 200, False when the endpoint rejects the image (400/413/
|
||||||
|
415/422), and None when we cannot decide (remote host, auth required,
|
||||||
|
unreachable, or probing disabled) so the caller falls back to name rules.
|
||||||
|
"""
|
||||||
|
if not endpoint_url or not model_name or not _vision_probe_enabled():
|
||||||
|
return None
|
||||||
|
parsed = urlparse(endpoint_url)
|
||||||
|
if not _is_local_host(parsed.hostname):
|
||||||
|
return None # never probe a public provider
|
||||||
|
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
||||||
|
now = time.time()
|
||||||
|
cached = _vision_probe_cache.get(key)
|
||||||
|
if cached is not None and cached[1] > now:
|
||||||
|
return cached[0]
|
||||||
|
payload = {
|
||||||
|
"model": model_name,
|
||||||
|
"max_tokens": 1,
|
||||||
|
"stream": False,
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Reply with one word."},
|
||||||
|
{"type": "image_url", "image_url": {"url": _PROBE_IMAGE_DATA_URL}},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
verdict: Optional[bool] = None
|
||||||
|
try:
|
||||||
|
r = httpx.post(_chat_completions_url(endpoint_url), json=payload, timeout=8.0)
|
||||||
|
if r.status_code == 200:
|
||||||
|
verdict = True
|
||||||
|
elif r.status_code in (400, 413, 415, 422):
|
||||||
|
body = (r.text or "").lower()
|
||||||
|
if any(tok in body for tok in ("image", "vision", "multimodal", "content type")):
|
||||||
|
verdict = False
|
||||||
|
else:
|
||||||
|
verdict = False
|
||||||
|
except Exception:
|
||||||
|
verdict = None
|
||||||
|
_vision_probe_cache[key] = (verdict, now + _VISION_PROBE_TTL)
|
||||||
|
if verdict is not None:
|
||||||
|
logger.info("vision probe %s@%s -> %s", model_name, parsed.hostname, verdict)
|
||||||
|
return verdict
|
||||||
|
|
||||||
|
|
||||||
def validate_message(message: str) -> str:
|
def validate_message(message: str) -> str:
|
||||||
"""Validate message input."""
|
"""Validate message input."""
|
||||||
if not message:
|
if not message:
|
||||||
|
|||||||
+644
-14
@@ -20,7 +20,11 @@ from pathlib import Path
|
|||||||
import httpx
|
import httpx
|
||||||
import jsonschema
|
import jsonschema
|
||||||
|
|
||||||
from src.context_compactor import prune_multimodal_images, trim_for_context
|
from src.context_compactor import (
|
||||||
|
keep_recent_image_turns,
|
||||||
|
prune_multimodal_images,
|
||||||
|
trim_for_context,
|
||||||
|
)
|
||||||
from src.agent_runtime.runtime_selection import COMPACT_PREVIEW_MODE
|
from src.agent_runtime.runtime_selection import COMPACT_PREVIEW_MODE
|
||||||
from src import agent_runs
|
from src import agent_runs
|
||||||
from src.agent_evidence import command_has_mutation_effect, workspace_artifact_is_usable
|
from src.agent_evidence import command_has_mutation_effect, workspace_artifact_is_usable
|
||||||
@@ -42,6 +46,7 @@ from src.turn_contract import (
|
|||||||
_REQUEST_PREFIX,
|
_REQUEST_PREFIX,
|
||||||
calendar_retiming_request,
|
calendar_retiming_request,
|
||||||
FAMILY_TOOLS, broad_web_briefing_request, required_read_operation_for_request,
|
FAMILY_TOOLS, broad_web_briefing_request, required_read_operation_for_request,
|
||||||
|
note_read_intent,
|
||||||
targets_bound_editor_request, inline_text_transformation, editor_request_instructions,
|
targets_bound_editor_request, inline_text_transformation, editor_request_instructions,
|
||||||
_bound_editor_requests_web_verification, scheduled_automation_request, creation_container_tool,
|
_bound_editor_requests_web_verification, scheduled_automation_request, creation_container_tool,
|
||||||
)
|
)
|
||||||
@@ -206,9 +211,17 @@ CONTRACT_REQUIRED_TOOLS = frozenset({
|
|||||||
})
|
})
|
||||||
from src.turn_contract import CONTRACT_CORE_TOOLS
|
from src.turn_contract import CONTRACT_CORE_TOOLS
|
||||||
|
|
||||||
|
# Scoped NL photo-editor surface. These are offered only when the
|
||||||
|
# ``photo_editor`` capability family is active (editor Quick Edit popup or an
|
||||||
|
# image-edit chat turn); they drive the in-process, owner-scoped editor bridge
|
||||||
|
# and the existing AI image ops.
|
||||||
|
EDITOR_AGENT_TOOLS = frozenset({
|
||||||
|
'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image',
|
||||||
|
})
|
||||||
PREVIEW_TOOLS = (
|
PREVIEW_TOOLS = (
|
||||||
READ_TOOLS | SAFE_WRITE_TOOLS | EXPLICIT_EXECUTE_TOOLS | SAFE_UI_TOOLS
|
READ_TOOLS | SAFE_WRITE_TOOLS | EXPLICIT_EXECUTE_TOOLS | SAFE_UI_TOOLS
|
||||||
| BROKERED_JOB_TOOLS | CONTRACT_REQUIRED_TOOLS | CONTRACT_CORE_TOOLS
|
| BROKERED_JOB_TOOLS | CONTRACT_REQUIRED_TOOLS | CONTRACT_CORE_TOOLS
|
||||||
|
| EDITOR_AGENT_TOOLS
|
||||||
)
|
)
|
||||||
# Keep a small recovery-capable surface on every interactive compact agent
|
# Keep a small recovery-capable surface on every interactive compact agent
|
||||||
# turn. Routing still adds domain tools, while policy and action guards remain
|
# turn. Routing still adds domain tools, while policy and action guards remain
|
||||||
@@ -1035,6 +1048,22 @@ def notes_terminal_response(raw, *, user_text='', max_items=20):
|
|||||||
return _bounded_structured_list(summary, user_text=user_text)
|
return _bounded_structured_list(summary, user_text=user_text)
|
||||||
|
|
||||||
|
|
||||||
|
def note_locator_followup(raw, *, user_text=''):
|
||||||
|
"""Require a body read before answering a note-content request."""
|
||||||
|
_, needs_body = note_read_intent(user_text)
|
||||||
|
if not needs_body or note_search_result_empty(raw):
|
||||||
|
return ''
|
||||||
|
listing = notes_terminal_response(raw, user_text=user_text, max_items=5)
|
||||||
|
match = re.search(r'\(#note-([A-Za-z0-9_-]+)\)', listing)
|
||||||
|
if not match:
|
||||||
|
return ''
|
||||||
|
return (
|
||||||
|
'The notes result contains titles only, not the body. '
|
||||||
|
f'Call manage_notes with action="view" and id="{match.group(1)}" now. '
|
||||||
|
'Use the returned body to answer the request; do not infer content from the title.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def documents_terminal_response(raw, *, user_text='', max_items=8):
|
def documents_terminal_response(raw, *, user_text='', max_items=8):
|
||||||
"""Render linked document rows under the user's explicit global cap."""
|
"""Render linked document rows under the user's explicit global cap."""
|
||||||
from src.agent_loop import _document_list_summary_from_tool_output
|
from src.agent_loop import _document_list_summary_from_tool_output
|
||||||
@@ -1368,17 +1397,26 @@ def contentless_final_response(content):
|
|||||||
|
|
||||||
|
|
||||||
def action_promise_response(content):
|
def action_promise_response(content):
|
||||||
"""Recognize a short promise to do work that contains no result yet."""
|
"""Recognize a short promise to do work that contains no result yet.
|
||||||
text = re.sub(r'\s+', ' ', str(content or '')).strip()
|
|
||||||
if not text or len(text.split()) > 48:
|
Fenced code blocks are stripped before the word count and the verb match,
|
||||||
|
so a promise followed by a code block (e.g. "I will browse Google Maps ..."
|
||||||
|
plus a requests/BeautifulSoup snippet and no tool call) still counts as an
|
||||||
|
action promise. The caller only reaches this when no tool call was made.
|
||||||
|
"""
|
||||||
|
without_code = re.sub(
|
||||||
|
r'\s+', ' ', re.sub(r'```.*?```', ' ', str(content or ''), flags=re.DOTALL)
|
||||||
|
).strip()
|
||||||
|
if not without_code or len(without_code.split()) > 48:
|
||||||
return False
|
return False
|
||||||
if re.search(r'\b(?:answer|result|score|served|created|saved)\s*:', text, re.I):
|
if re.search(r'\b(?:answer|result|score|served|created|saved)\s*:', without_code, re.I):
|
||||||
return False
|
return False
|
||||||
return bool(re.match(
|
return bool(re.match(
|
||||||
r"^(?:okay[,.:]?\s*)?(?:let me|i(?:'ll| will)|next[,.:]?\s+i(?:'ll| will))\s+"
|
r"^(?:okay[,.:]?\s*)?(?:let me|i(?:'ll| will)|next[,.:]?\s+i(?:'ll| will))\s+"
|
||||||
r"(?:now\s+)?(?:inspect|extract|read|check|review|analy[sz]e|verify|open|"
|
r"(?:now\s+)?(?:inspect|extract|read|check|review|analy[sz]e|verify|open|"
|
||||||
r"search|look|create|write|render|run|use|continue|finish|provide)\b",
|
r"search|look|create|write|render|run|use|continue|finish|provide|"
|
||||||
text,
|
r"browse|find|fetch|visit|navigate|go|get|scrape)\b",
|
||||||
|
without_code,
|
||||||
re.I,
|
re.I,
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -1407,6 +1445,191 @@ def progressive_thinking_for_turn(model, offered_schemas, thinking_mode=None):
|
|||||||
return uses_odysseus_progressive_thinking(model) and not bool(offered_schemas)
|
return uses_odysseus_progressive_thinking(model) and not bool(offered_schemas)
|
||||||
|
|
||||||
|
|
||||||
|
def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_user_text):
|
||||||
|
"""Env-gated router decision for spec tool-surface turns. Fail open (None)."""
|
||||||
|
try:
|
||||||
|
import sys as _sys
|
||||||
|
spec_router = _sys.modules.get('src.spec_router')
|
||||||
|
if spec_router is None:
|
||||||
|
from src import spec_router # noqa: F401
|
||||||
|
if not spec_router.think_router_enabled():
|
||||||
|
return None
|
||||||
|
from src.agent_loop import _configured_model_tool_surface_raw
|
||||||
|
if _configured_model_tool_surface_raw(
|
||||||
|
endpoint_url, model, owner, headers=headers
|
||||||
|
) != 'spec':
|
||||||
|
return None
|
||||||
|
return spec_router.route_thinking(direct_user_text)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
'spec think router decision failed; using rules only', exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def spec_router_think_reason(round_number, decision, existing_deliberate):
|
||||||
|
"""Add 'router_think' on round 1 when the router asked for thinking and no
|
||||||
|
other rule already selected a deliberate reason."""
|
||||||
|
if (round_number == 1 and not existing_deliberate
|
||||||
|
and isinstance(decision, dict) and decision.get('think')):
|
||||||
|
return 'router_think'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def spec_direct_mode():
|
||||||
|
"""Which direct-answer behaviour to use when the gate is on.
|
||||||
|
|
||||||
|
'tool_choice_none' (default) keeps the tools list but forbids a tool call
|
||||||
|
for the round; 'empty_menu' sends no tools at all. Any other value falls
|
||||||
|
back to the default.
|
||||||
|
"""
|
||||||
|
mode = str(
|
||||||
|
os.getenv('ODYSSEUS_SPEC_DIRECT_MODE', 'tool_choice_none') or ''
|
||||||
|
).strip().lower()
|
||||||
|
return 'empty_menu' if mode == 'empty_menu' else 'tool_choice_none'
|
||||||
|
|
||||||
|
|
||||||
|
def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
|
||||||
|
"""Env-gated confidence for a spec 'answer_directly' turn.
|
||||||
|
|
||||||
|
Returns the answer_directly probability when the spec router just decided
|
||||||
|
to answer directly with enough confidence, else None. Default OFF, so the
|
||||||
|
caller behaves exactly as before unless ODYSSEUS_SPEC_DIRECT_NOTOOLS is set.
|
||||||
|
"""
|
||||||
|
if str(os.getenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '')).strip().lower() not in (
|
||||||
|
'1', 'true', 'yes', 'on'):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from src.agent_loop import _configured_model_tool_surface_raw
|
||||||
|
if _configured_model_tool_surface_raw(
|
||||||
|
endpoint_url, model, owner, headers=headers
|
||||||
|
) != 'spec':
|
||||||
|
return None
|
||||||
|
import sys as _sys
|
||||||
|
spec_router = _sys.modules.get('src.spec_router')
|
||||||
|
if spec_router is None:
|
||||||
|
from src import spec_router # noqa: F401
|
||||||
|
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
||||||
|
if not decisions:
|
||||||
|
return None
|
||||||
|
decision = decisions[-1]
|
||||||
|
if not isinstance(decision, dict):
|
||||||
|
return None
|
||||||
|
# Guard against a stale decision from a previous turn.
|
||||||
|
msg = str(user_message or '')
|
||||||
|
decided_msg = str(decision.get('message') or '')
|
||||||
|
if decided_msg and msg and decided_msg[:40] != msg[:40]:
|
||||||
|
return None
|
||||||
|
if decision.get('choice') != 'answer_directly':
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
p = float(decision.get('answer_directly') or 0.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
min_p = float(os.getenv('ODYSSEUS_SPEC_DIRECT_MIN_P', '0.9') or 0.9)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
min_p = 0.9
|
||||||
|
return p if p >= min_p else None
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
'spec direct-answer decision failed; using rules only', exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def spec_driver_active(endpoint_url, model, owner, headers):
|
||||||
|
"""True when ODYSSEUS_SPEC_MODE=driver and the turn uses the spec surface."""
|
||||||
|
if str(os.getenv('ODYSSEUS_SPEC_MODE', '') or '').strip().lower() != 'driver':
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from src.agent_loop import _configured_model_tool_surface_raw
|
||||||
|
return _configured_model_tool_surface_raw(
|
||||||
|
endpoint_url, model, owner, headers=headers) == 'spec'
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
|
||||||
|
"""Per-round ODYSSEUS_SPEC_MODE=driver decision: the router drives tools.
|
||||||
|
|
||||||
|
Ask the router once for this round and return its TOP-1 choice at ANY
|
||||||
|
confidence: ``{'choice': 'tool'|'answer_directly'|'ask_user', 'p': float,
|
||||||
|
'tool_name': str|None}``. Returns None on any router error/timeout or an
|
||||||
|
unusable pick, so the caller keeps the normal menu for that round.
|
||||||
|
|
||||||
|
``schemas`` is the round's offered menu; the router only ever picks from
|
||||||
|
it, so a top-1 tool is always one the turn can actually execute (passing
|
||||||
|
every known tool here made the driver name tools outside the offered set,
|
||||||
|
which then looped on permission errors).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||||
|
import sys as _sys
|
||||||
|
spec_router = _sys.modules.get('src.spec_router')
|
||||||
|
if spec_router is None:
|
||||||
|
from src import spec_router # noqa: F401
|
||||||
|
menu = list(schemas) if schemas else list(FUNCTION_TOOL_SCHEMAS)
|
||||||
|
picks, _decided = spec_router.spec_route_tools(
|
||||||
|
str(user_text or ''), menu,
|
||||||
|
set(disabled_tools or ()), list(conversation or []),
|
||||||
|
return_decision=True,
|
||||||
|
)
|
||||||
|
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
||||||
|
decision = decisions[-1] if decisions else {}
|
||||||
|
if not isinstance(decision, dict) or decision.get('error'):
|
||||||
|
return None
|
||||||
|
choice = decision.get('choice')
|
||||||
|
top = decision.get('top') or []
|
||||||
|
if choice == 'answer_directly':
|
||||||
|
return {'choice': 'answer_directly',
|
||||||
|
'p': float(decision.get('answer_directly') or 0.0),
|
||||||
|
'tool_name': None}
|
||||||
|
if choice == 'ask_user':
|
||||||
|
return {'choice': 'ask_user',
|
||||||
|
'p': float(decision.get('ask_user') or 0.0),
|
||||||
|
'tool_name': None}
|
||||||
|
# Driver mode takes the router's TOP-1 at ANY confidence, so read the
|
||||||
|
# ranked ``top`` (not the MIN_CONF-filtered ``picks``/``added``).
|
||||||
|
if top and isinstance(top[0], (list, tuple)) and top[0]:
|
||||||
|
name = str(top[0][0] or '')
|
||||||
|
p = None
|
||||||
|
if len(top[0]) >= 2:
|
||||||
|
try:
|
||||||
|
p = float(top[0][1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
p = None
|
||||||
|
if name:
|
||||||
|
return {'choice': 'tool', 'p': p, 'tool_name': name}
|
||||||
|
if picks:
|
||||||
|
try:
|
||||||
|
name = str(next(iter(picks)))
|
||||||
|
except TypeError:
|
||||||
|
name = str(picks[0]) if len(picks) else ''
|
||||||
|
if name:
|
||||||
|
return {'choice': 'tool', 'p': None, 'tool_name': name}
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
'spec driver decision failed; using the normal menu', exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def tool_deliberation_reason(name, args):
|
||||||
|
"""Classify selected operations, never infer intent from tool availability."""
|
||||||
|
name = canonical(name)
|
||||||
|
if name in {'python', 'edit_file', 'apply_patch'}:
|
||||||
|
return 'code'
|
||||||
|
if name in {'edit_document', 'suggest_document', 'update_document'}:
|
||||||
|
return 'document_edit'
|
||||||
|
if name in {'draft_email', 'draft_email_reply', 'ai_draft_email_reply'}:
|
||||||
|
return 'email_draft'
|
||||||
|
action = str(args.get('action') or '').lower()
|
||||||
|
if name == 'manage_tasks' and action in {'create', 'add', 'update', 'edit'}:
|
||||||
|
return 'task_design'
|
||||||
|
if name == 'manage_skills' and action in {'add', 'edit', 'patch', 'publish', 'audit'}:
|
||||||
|
return 'skill_evaluation'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def visible_content_after_qwen_thinking(content):
|
def visible_content_after_qwen_thinking(content):
|
||||||
"""Remove private Qwen reasoning when the server lacks a reasoning parser."""
|
"""Remove private Qwen reasoning when the server lacks a reasoning parser."""
|
||||||
|
|
||||||
@@ -1422,6 +1645,48 @@ def visible_content_after_qwen_thinking(content):
|
|||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def qwen_think_split_events(content, state):
|
||||||
|
"""Split a no-reasoning-parser thinking round into live deltas.
|
||||||
|
|
||||||
|
Some thinking servers (no --reasoning-parser) return the whole
|
||||||
|
``<think>…</think>`` trace as ordinary ``content``. Buffering that round
|
||||||
|
made the UI show nothing while the model thought and then dump the whole
|
||||||
|
answer at once. Stream the trace as ``{'delta': ..., 'thinking': True}``
|
||||||
|
events (the same shape a reasoning-parser server emits) and the text after
|
||||||
|
``</think>`` as normal answer deltas. ``state`` is a mutable per-round dict
|
||||||
|
carrying the emission bookkeeping; the stored answer is unchanged (see
|
||||||
|
``visible_content_after_qwen_thinking``).
|
||||||
|
"""
|
||||||
|
events = []
|
||||||
|
text = str(content or '')
|
||||||
|
if not text:
|
||||||
|
return events
|
||||||
|
lowered = text.casefold()
|
||||||
|
lead = len('<think>') if lowered.startswith('<think>') else 0
|
||||||
|
close = lowered.find('</think>')
|
||||||
|
if close < 0:
|
||||||
|
# The opening <think> is injected by the chat template, so a progressive
|
||||||
|
# trace can arrive with no marker until </think>. Emit the buffered text
|
||||||
|
# as thinking - holding back a possible partial close tag - so the trace
|
||||||
|
# streams live instead of dumping in one block at the end.
|
||||||
|
think_region = text[lead:max(lead, len(text) - len('</think>'))]
|
||||||
|
answer_region = ''
|
||||||
|
else:
|
||||||
|
think_region = text[lead:close]
|
||||||
|
answer_region = text[close + len('</think>'):]
|
||||||
|
emitted_think = int(state.get('think_emitted', 0) or 0)
|
||||||
|
if len(think_region) > emitted_think:
|
||||||
|
events.append({'delta': think_region[emitted_think:], 'thinking': True})
|
||||||
|
state['think_emitted'] = len(think_region)
|
||||||
|
if close >= 0:
|
||||||
|
state['closed'] = True
|
||||||
|
emitted_answer = int(state.get('answer_emitted', 0) or 0)
|
||||||
|
if len(answer_region) > emitted_answer:
|
||||||
|
events.append({'delta': answer_region[emitted_answer:]})
|
||||||
|
state['answer_emitted'] = len(answer_region)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
def prior_short_answer_for_no_tool_summary(user_text, history):
|
def prior_short_answer_for_no_tool_summary(user_text, history):
|
||||||
"""Reuse the immediately prior concise answer for an explicit no-tool recap."""
|
"""Reuse the immediately prior concise answer for an explicit no-tool recap."""
|
||||||
text = str(user_text or '')
|
text = str(user_text or '')
|
||||||
@@ -2714,6 +2979,18 @@ def normalize_preview_entity_anchor_args(name, args):
|
|||||||
value = args.get(field)
|
value = args.get(field)
|
||||||
if isinstance(value, str) and value.startswith(prefix):
|
if isinstance(value, str) and value.startswith(prefix):
|
||||||
args[field] = value[len(prefix):]
|
args[field] = value[len(prefix):]
|
||||||
|
if canonical(name) == 'manage_notes':
|
||||||
|
# Some models copy the rendered note href without its leading '#'.
|
||||||
|
# Decode only a complete UUID; partial selectors retain the existing
|
||||||
|
# owner-bound resolution policy instead of gaining a new alias.
|
||||||
|
value = args.get('note_id')
|
||||||
|
if isinstance(value, str):
|
||||||
|
match = re.fullmatch(
|
||||||
|
r'note-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
|
||||||
|
value, re.I,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
args['note_id'] = match.group(1)
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
@@ -2754,6 +3031,9 @@ def normalize_preview_function_args(name, args, *, user_text=''):
|
|||||||
args.pop('filter', None)
|
args.pop('filter', None)
|
||||||
if canonical(name) == 'manage_notes':
|
if canonical(name) == 'manage_notes':
|
||||||
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
|
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
|
||||||
|
if action == 'read' and (args.get('id') or args.get('note_id')):
|
||||||
|
args['action'] = 'view'
|
||||||
|
action = 'view'
|
||||||
if action == 'list':
|
if action == 'list':
|
||||||
request = str(user_text or '').casefold()
|
request = str(user_text or '').casefold()
|
||||||
# The notes backend interprets title/query/content on ``list`` as
|
# The notes backend interprets title/query/content on ``list`` as
|
||||||
@@ -2770,6 +3050,10 @@ def normalize_preview_function_args(name, args, *, user_text=''):
|
|||||||
args.pop('archived', None)
|
args.pop('archived', None)
|
||||||
if not re.search(r'\b(?:checklists?|freeform\s+notes?|notes?\s+only)\b', request):
|
if not re.search(r'\b(?:checklists?|freeform\s+notes?|notes?\s+only)\b', request):
|
||||||
args.pop('note_type', None)
|
args.pop('note_type', None)
|
||||||
|
query, _ = note_read_intent(user_text)
|
||||||
|
if query:
|
||||||
|
args['action'] = 'search'
|
||||||
|
args['query'] = query
|
||||||
if canonical(name) == 'manage_skills':
|
if canonical(name) == 'manage_skills':
|
||||||
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
|
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
|
||||||
if action in {'update', 'change', 'revise'}:
|
if action in {'update', 'change', 'revise'}:
|
||||||
@@ -2897,8 +3181,9 @@ def scope_preview_contract(preview_contract, routed_contract, active_capabilitie
|
|||||||
requested_family_tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in active))
|
requested_family_tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in active))
|
||||||
available_requested = routed_canonical & offered_canonical & requested_family_tools
|
available_requested = routed_canonical & offered_canonical & requested_family_tools
|
||||||
routed_canonical.update(canonical(name) for name in extra_tools)
|
routed_canonical.update(canonical(name) for name in extra_tools)
|
||||||
if 'image_generation' not in active:
|
if 'image_generation' not in active and 'photo_editor' not in active:
|
||||||
# A past image request must not keep generation warm on email/doc turns.
|
# A past image request must not keep generation warm on email/doc turns,
|
||||||
|
# unless the photo_editor family (which bundles generate_image) is active.
|
||||||
routed_canonical.discard('generate_image')
|
routed_canonical.discard('generate_image')
|
||||||
missing_active = {
|
missing_active = {
|
||||||
f'capability:{family}' for family in active
|
f'capability:{family}' for family in active
|
||||||
@@ -3230,7 +3515,11 @@ def _successful_email_identifiers(history):
|
|||||||
for message in history or ():
|
for message in history or ():
|
||||||
if not isinstance(message, dict):
|
if not isinstance(message, dict):
|
||||||
continue
|
continue
|
||||||
if message.get('role') == 'system' and 'Message UID:' in str(message.get('content') or ''):
|
active_reader = (
|
||||||
|
message.get('_agent_injected') == 'context'
|
||||||
|
and (message.get('metadata') or {}).get('source') == 'active email reader'
|
||||||
|
)
|
||||||
|
if (message.get('role') == 'system' or active_reader) and 'Message UID:' in str(message.get('content') or ''):
|
||||||
extracted = _email_identifiers_from_text(message.get('content'))
|
extracted = _email_identifiers_from_text(message.get('content'))
|
||||||
known['uid'].update(extracted['uid'])
|
known['uid'].update(extracted['uid'])
|
||||||
known['message_id'].update(extracted['message_id'])
|
known['message_id'].update(extracted['message_id'])
|
||||||
@@ -3518,6 +3807,20 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
|
|||||||
runtime_tools = PREVIEW_TOOLS | (
|
runtime_tools = PREVIEW_TOOLS | (
|
||||||
NATIVE_WORKSPACE_TOOLS if allow_native_workspace else frozenset()
|
NATIVE_WORKSPACE_TOOLS if allow_native_workspace else frozenset()
|
||||||
) | ({canonical(tool) for tool in external_runtime_tools} if allow_native_workspace else set())
|
) | ({canonical(tool) for tool in external_runtime_tools} if allow_native_workspace else set())
|
||||||
|
# Spec turns are driven by the router: the Spec contract (its offered set)
|
||||||
|
# IS the model runtime, so a Spec pick is runnable. Keyed off the same
|
||||||
|
# marker the spec hook sets (`routing_experiment == 'spec-router'`), never
|
||||||
|
# a new flag, and never applied to compact/native turns.
|
||||||
|
try:
|
||||||
|
from src.turn_contract import active_turn_contract as _atc
|
||||||
|
_spec_contract = _atc()
|
||||||
|
if _spec_contract is not None and getattr(
|
||||||
|
_spec_contract, 'routing_experiment', '') == 'spec-router':
|
||||||
|
runtime_tools = runtime_tools | {
|
||||||
|
canonical(tool) for tool in getattr(_spec_contract, 'offered', ()) or ()
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if bare not in runtime_tools:
|
if bare not in runtime_tools:
|
||||||
return decision(False, 'tool_not_in_model_runtime')
|
return decision(False, 'tool_not_in_model_runtime')
|
||||||
if bare in {canonical(tool) for tool in external_runtime_tools}:
|
if bare in {canonical(tool) for tool in external_runtime_tools}:
|
||||||
@@ -3619,6 +3922,18 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
|
|||||||
# editor is offered only for an explicit image-editing turn.
|
# editor is offered only for an explicit image-editing turn.
|
||||||
blocked_effects.remove(ToolEffect.NETWORK_EGRESS)
|
blocked_effects.remove(ToolEffect.NETWORK_EGRESS)
|
||||||
allowed_effects.add(ToolEffect.NETWORK_EGRESS)
|
allowed_effects.add(ToolEffect.NETWORK_EGRESS)
|
||||||
|
if (
|
||||||
|
(contract_required or family in turn_authorized_families)
|
||||||
|
and bare in {'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image'}
|
||||||
|
):
|
||||||
|
# Scoped photo_editor agent tools. They are never in the general tool
|
||||||
|
# list; the immutable turn contract only selects them for an explicit
|
||||||
|
# editor request (Quick Edit popup / image-edit turn), so their UI and
|
||||||
|
# brokered-network effects are covered by the contract seal.
|
||||||
|
blocked_effects.discard(ToolEffect.UI_SIDE_EFFECT)
|
||||||
|
allowed_effects.add(ToolEffect.UI_SIDE_EFFECT)
|
||||||
|
blocked_effects.discard(ToolEffect.NETWORK_EGRESS)
|
||||||
|
allowed_effects.add(ToolEffect.NETWORK_EGRESS)
|
||||||
if (
|
if (
|
||||||
contract_required
|
contract_required
|
||||||
and bare in {'download_model', 'serve_preset', 'stop_served_model'}
|
and bare in {'download_model', 'serve_preset', 'stop_served_model'}
|
||||||
@@ -3849,6 +4164,29 @@ def conversation(history_session, messages, *, owner=None, diagnostics=None):
|
|||||||
from src.turn_contract import result_reference_followup
|
from src.turn_contract import result_reference_followup
|
||||||
source_context = (recent_source_reference_context(groups[-2])
|
source_context = (recent_source_reference_context(groups[-2])
|
||||||
if len(groups) > 1 and result_reference_followup(current_text) else None)
|
if len(groups) > 1 and result_reference_followup(current_text) else None)
|
||||||
|
# Tool output can dwarf the dialogue (especially a web search). Keep the
|
||||||
|
# visible exchange before enforcing the history cap, so one search-heavy
|
||||||
|
# turn cannot erase the subject of the next follow-up.
|
||||||
|
for index, group in enumerate(groups[:-1]):
|
||||||
|
if len(json.dumps(group)) <= 5000 or len(group) < 2:
|
||||||
|
continue
|
||||||
|
# Keep non-web tool evidence intact: a note/list follow-up may need
|
||||||
|
# exact result identifiers, and shortening it would be misleading.
|
||||||
|
if not any(
|
||||||
|
canonical((call.get('function') or {}).get('name', '')) in {'web_search', 'web_fetch'}
|
||||||
|
for message in group if message.get('role') == 'assistant'
|
||||||
|
for call in message.get('tool_calls') or ()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
answer = next((message.get('content') for message in reversed(group)
|
||||||
|
if message.get('role') == 'assistant'
|
||||||
|
and str(message.get('content') or '').strip()), None)
|
||||||
|
if answer is None:
|
||||||
|
continue
|
||||||
|
answer_text = str(answer)
|
||||||
|
if len(answer_text) > 3500:
|
||||||
|
answer_text = answer_text[:3500] + '\n[Earlier answer shortened for context.]'
|
||||||
|
groups[index] = [group[0], {'role': 'assistant', 'content': answer_text}]
|
||||||
# Drop whole turns only, never orphan tool results from their native calls.
|
# Drop whole turns only, never orphan tool results from their native calls.
|
||||||
groups = groups[-8:]
|
groups = groups[-8:]
|
||||||
while len(groups) > 1 and len(json.dumps(groups)) > 22000:
|
while len(groups) > 1 and len(json.dumps(groups)) > 22000:
|
||||||
@@ -4207,6 +4545,26 @@ def denied_response():
|
|||||||
return 'I can’t perform that operation in this preview. No changes were made.'
|
return 'I can’t perform that operation in this preview. No changes were made.'
|
||||||
|
|
||||||
|
|
||||||
|
WORKSPACE_REQUIRED_REFUSAL = (
|
||||||
|
'Shell and Python commands only run inside a workspace. '
|
||||||
|
'Open the code workspace (or set a working directory) and try again.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_process_denial(executions) -> bool:
|
||||||
|
"""True when a blocked call was refused because process execution needs a
|
||||||
|
sealed workspace (a plain chat turn carries none)."""
|
||||||
|
for entry in executions or ():
|
||||||
|
blob = ' '.join(
|
||||||
|
str(entry.get(key) or '') for key in ('error', 'output', 'failure_kind')
|
||||||
|
)
|
||||||
|
# Resource identity also covers missing note and document selectors.
|
||||||
|
# Those denials must not turn into instructions to open a code workspace.
|
||||||
|
if 'sealed workspace' in blob.casefold():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def execution_has_write_effect(tool_name, content, capability, *, native_workspace_enabled):
|
def execution_has_write_effect(tool_name, content, capability, *, native_workspace_enabled):
|
||||||
"""Recognize successful native code that visibly mutates the workspace."""
|
"""Recognize successful native code that visibly mutates the workspace."""
|
||||||
|
|
||||||
@@ -4226,7 +4584,7 @@ _MUTATION_REQUEST = re.compile(
|
|||||||
r'\b(?:add|creat(?:e|ing)?|make|writ(?:e|ing)|sav(?:e|ing)|updat(?:e|ing)|edit(?:ing)?|chang(?:e|ing)|'
|
r'\b(?:add|creat(?:e|ing)?|make|writ(?:e|ing)|sav(?:e|ing)|updat(?:e|ing)|edit(?:ing)?|chang(?:e|ing)|'
|
||||||
r'set|schedul(?:e|ing)|reschedul(?:e|ing)|paus(?:e|ing)|resum(?:e|ing)|toggle|mark|'
|
r'set|schedul(?:e|ing)|reschedul(?:e|ing)|paus(?:e|ing)|resum(?:e|ing)|toggle|mark|'
|
||||||
r'pin|archive|delet(?:e|ing)|remov(?:e|ing)|send|reply|draft|publish|run|launch|serve|start|stop|'
|
r'pin|archive|delet(?:e|ing)|remov(?:e|ing)|send|reply|draft|publish|run|launch|serve|start|stop|'
|
||||||
r'remember|remeber|forget|remind|review|proofread|suggest(?:ions?)?|expand|broaden|deepen|lighten|feedback|reserve|block)\b|'
|
r'remember|remeber|forget|remind|review|proofread|suggest(?:ions?)?|expand|broaden|deepen|lighten|reserve|block)\b|'
|
||||||
r'\bgo\s+deeper\b',
|
r'\bgo\s+deeper\b',
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
@@ -4262,7 +4620,7 @@ def mutation_action_requested(user_text):
|
|||||||
actionable = re.sub(
|
actionable = re.sub(
|
||||||
r"\b(?:do\s+not|don't|never|without)\s+"
|
r"\b(?:do\s+not|don't|never|without)\s+"
|
||||||
r"(?:(?:chang(?:e|ing)|modif(?:y|ying)|edit(?:ing)?|delet(?:e|ing)|"
|
r"(?:(?:chang(?:e|ing)|modif(?:y|ying)|edit(?:ing)?|delet(?:e|ing)|"
|
||||||
r"remov(?:e|ing)|send(?:ing)?|writ(?:e|ing)|creat(?:e|ing))\b)"
|
r"remov(?:e|ing)|send(?:ing)?|writ(?:e|ing)|creat(?:e|ing)|schedul(?:e|ing))\b)"
|
||||||
r"[^.;\n]{0,120}?(?=(?:[.;\n]|\bbut\b|\binstead\b|$))",
|
r"[^.;\n]{0,120}?(?=(?:[.;\n]|\bbut\b|\binstead\b|$))",
|
||||||
'', text, flags=re.I,
|
'', text, flags=re.I,
|
||||||
)
|
)
|
||||||
@@ -5622,6 +5980,38 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
'multimodal_image_count': image_context_count,
|
'multimodal_image_count': image_context_count,
|
||||||
'attachment_reference_count': attachment_refs,
|
'attachment_reference_count': attachment_refs,
|
||||||
'image_rehydration': conversation_diagnostics.get('image_rehydration')})
|
'image_rehydration': conversation_diagnostics.get('image_rehydration')})
|
||||||
|
_photo_editor_active = 'photo_editor' in (getattr(turn_contract, 'active_capabilities', ()) or ())
|
||||||
|
_photo_editor_open = bool(
|
||||||
|
((client_runtime_context or {}).get('web_ui_state') or {}).get('photo_editor_open')
|
||||||
|
)
|
||||||
|
if _photo_editor_active and not _photo_editor_open:
|
||||||
|
_editor_guidance = (
|
||||||
|
"This image isn't open in the editor yet, so I can't apply edits to the "
|
||||||
|
"canvas from chat. Open the attached image in the editor (Quick Edit) and "
|
||||||
|
"send this request there \u2014 I'll apply it to the canvas."
|
||||||
|
)
|
||||||
|
history.append({'role': 'assistant', 'content': _editor_guidance})
|
||||||
|
yield event({'delta': _editor_guidance})
|
||||||
|
_action_prompt = str(direct_user_text or '')
|
||||||
|
for _annotation in ('\n\n[Image:', '\n[Image:', '\n\n[Attached', '\n[No vision model'):
|
||||||
|
_cut = _action_prompt.find(_annotation)
|
||||||
|
if _cut != -1:
|
||||||
|
_action_prompt = _action_prompt[:_cut]
|
||||||
|
_action_prompt = _action_prompt.strip() or str(direct_user_text or '').strip()
|
||||||
|
yield event({
|
||||||
|
'type': 'final_response',
|
||||||
|
'content': _editor_guidance,
|
||||||
|
'photo_editor_action': {
|
||||||
|
'kind': 'open_and_apply',
|
||||||
|
'prompt': _action_prompt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
yield event({'type': 'metrics', 'data': {
|
||||||
|
'model': model, 'rounds': 0, 'round_texts': [_editor_guidance],
|
||||||
|
'tool_events': [], 'tool_calls': 0, 'agent_rounds': 0,
|
||||||
|
}})
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=preview_http_timeout(
|
timeout=preview_http_timeout(
|
||||||
@@ -5703,6 +6093,35 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
usage_in += intent_accounting.get('input_tokens', 0)
|
usage_in += intent_accounting.get('input_tokens', 0)
|
||||||
usage_out += intent_accounting.get('output_tokens', 0)
|
usage_out += intent_accounting.get('output_tokens', 0)
|
||||||
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
||||||
|
# Optional env-gated router decision (spec surface only). Computed
|
||||||
|
# once per user turn; disabled/None leaves behaviour unchanged.
|
||||||
|
_spec_route_think = spec_router_thinking_decision(
|
||||||
|
endpoint_url, model, owner, headers, direct_user_text)
|
||||||
|
if _spec_route_think is not None:
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
'spec think router: think=%s p_think=%s ms=%s',
|
||||||
|
_spec_route_think.get('think'),
|
||||||
|
_spec_route_think.get('p_think'),
|
||||||
|
_spec_route_think.get('ms'))
|
||||||
|
# Optional env-gated direct-answer override (spec surface only):
|
||||||
|
# when the spec router chose answer_directly with high confidence,
|
||||||
|
# round 1 is sent with tool_choice 'none' (same tools list).
|
||||||
|
_spec_direct_p = spec_direct_no_tools_p(
|
||||||
|
direct_user_text, endpoint_url, model, owner, headers)
|
||||||
|
# Option B: send round 1 with no tools at all for a direct answer.
|
||||||
|
_spec_direct_empty_menu = (
|
||||||
|
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
|
||||||
|
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
|
||||||
|
# every round; the 9B only writes the arguments or the answer.
|
||||||
|
_spec_driver_on = spec_driver_active(endpoint_url, model, owner, headers)
|
||||||
|
# Driver mode narrows the contract per round to the Spec pick.
|
||||||
|
# Rebuild each round from this frozen base so the narrowing does
|
||||||
|
# not accumulate/mutate across rounds.
|
||||||
|
_driver_base_contract = turn_contract
|
||||||
|
# Per-round Spec pick (driver mode): 'answer_directly' | 'ask_user'
|
||||||
|
# | 'tool' | None (router fallback). Set inside the round loop.
|
||||||
|
_driver_round_kind = None
|
||||||
|
_driver_round_pick = None
|
||||||
# One extra iteration is available only when a provider emits raw
|
# One extra iteration is available only when a provider emits raw
|
||||||
# tool markup during the normal final no-tools round. Ordinary
|
# tool markup during the normal final no-tools round. Ordinary
|
||||||
# turns still obey ``round_limit`` exactly.
|
# turns still obey ``round_limit`` exactly.
|
||||||
@@ -5839,7 +6258,9 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
'completion_reserve_seconds': artifact_completion_reserve_seconds,
|
'completion_reserve_seconds': artifact_completion_reserve_seconds,
|
||||||
})
|
})
|
||||||
request_messages = provider_request_messages(
|
request_messages = provider_request_messages(
|
||||||
prune_multimodal_images(history, max_images=3)
|
prune_multimodal_images(
|
||||||
|
keep_recent_image_turns(history, keep=2), max_images=3
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if prior_summary_answer or force_no_tools_next_round or reserve_final_synthesis:
|
if prior_summary_answer or force_no_tools_next_round or reserve_final_synthesis:
|
||||||
round_offered = []
|
round_offered = []
|
||||||
@@ -6036,6 +6457,94 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
break
|
break
|
||||||
request['tools'] = source_tools
|
request['tools'] = source_tools
|
||||||
request['tool_choice'] = 'required'
|
request['tool_choice'] = 'required'
|
||||||
|
# ODYSSEUS_SPEC_MODE=driver: the router is the tool driver.
|
||||||
|
# Ask it for this round's single next action (any confidence);
|
||||||
|
# the 9B then only writes the arguments (a named tool_choice) or
|
||||||
|
# the answer (tools key omitted; vLLM rejects an empty array).
|
||||||
|
# Router error/timeout keeps the normal menu for this round.
|
||||||
|
if _spec_driver_on and round_offered:
|
||||||
|
# The router chooses from the FULL Ajax Spec surface (~all
|
||||||
|
# built-ins minus disabled), not the compact round menu, so
|
||||||
|
# a Spec pick is never silently out of reach. The 9B then
|
||||||
|
# only writes the arguments (a named tool_choice) or the
|
||||||
|
# answer (tools key omitted; vLLM rejects an empty array).
|
||||||
|
# Router error/timeout keeps the normal menu this round.
|
||||||
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS as _SPEC_SURFACE
|
||||||
|
_drv = spec_driver_pick(history, disabled_tools, direct_user_text)
|
||||||
|
if _drv is not None:
|
||||||
|
_drv_choice = _drv.get('choice')
|
||||||
|
if _drv_choice == 'answer_directly':
|
||||||
|
_driver_round_kind = 'answer_directly'
|
||||||
|
request.pop('tools', None)
|
||||||
|
request.pop('tool_choice', None)
|
||||||
|
elif _drv_choice == 'ask_user':
|
||||||
|
_driver_round_kind = 'ask_user'
|
||||||
|
_ask = [s for s in _SPEC_SURFACE
|
||||||
|
if (s.get('function') or {}).get('name') == 'ask_user']
|
||||||
|
if _ask:
|
||||||
|
request['tools'] = [_ask[0]]
|
||||||
|
request['tool_choice'] = {
|
||||||
|
'type': 'function',
|
||||||
|
'function': {'name': 'ask_user'},
|
||||||
|
}
|
||||||
|
if _ask[0] not in round_offered:
|
||||||
|
round_offered = list(round_offered) + [_ask[0]]
|
||||||
|
elif _drv_choice == 'tool' and _drv.get('tool_name'):
|
||||||
|
_name = _drv['tool_name']
|
||||||
|
_sel = [s for s in list(round_offered) + list(_SPEC_SURFACE)
|
||||||
|
if (s.get('function') or {}).get('name') == _name]
|
||||||
|
if _sel:
|
||||||
|
_driver_round_kind = 'tool'
|
||||||
|
_driver_round_pick = _name
|
||||||
|
request['tools'] = [_sel[0]]
|
||||||
|
request['tool_choice'] = {
|
||||||
|
'type': 'function',
|
||||||
|
'function': {'name': _name},
|
||||||
|
}
|
||||||
|
# The per-call validator looks the schema up in
|
||||||
|
# `round_offered` (not `request['tools']`), so the
|
||||||
|
# Spec pick must be present there too.
|
||||||
|
round_offered = [_sel[0]]
|
||||||
|
# Driver mode narrows the whole contract to the
|
||||||
|
# Spec pick for this round: the router is the
|
||||||
|
# authority, and the executor must be able to run
|
||||||
|
# the picked tool. Build a valid contract (the
|
||||||
|
# TurnContract invariant requires
|
||||||
|
# required <= offered <= executable and
|
||||||
|
# schema_json names == offered).
|
||||||
|
try:
|
||||||
|
from src.turn_contract import (
|
||||||
|
_ACTIVE_CONTRACT as _TC,
|
||||||
|
canonical_tool as _ct,
|
||||||
|
)
|
||||||
|
_pick = _ct(_name)
|
||||||
|
turn_contract = replace(
|
||||||
|
_driver_base_contract,
|
||||||
|
required=frozenset(),
|
||||||
|
offered=frozenset({_pick}),
|
||||||
|
executable=frozenset(
|
||||||
|
set(_driver_base_contract.executable) | {_pick}),
|
||||||
|
schema_json=(json.dumps(_sel[0]),),
|
||||||
|
required_read_operation=None,
|
||||||
|
)
|
||||||
|
_TC.set(turn_contract)
|
||||||
|
except Exception:
|
||||||
|
turn_contract = _driver_base_contract
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
'[spec-driver] round=%s pick=%s contract admit failed; '
|
||||||
|
'normal contract kept', round_number, _name)
|
||||||
|
else:
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
'[spec-driver] round=%s pick=%s not executable; normal menu this round',
|
||||||
|
round_number, _name)
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
'[spec-driver] round=%s pick=%s p=%s', round_number,
|
||||||
|
(_drv.get('tool_name') if _drv_choice == 'tool'
|
||||||
|
else _drv_choice),
|
||||||
|
_drv.get('p'))
|
||||||
|
else:
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
'[spec-driver] round=%s pick=fallback p=None', round_number)
|
||||||
pending, content, round_reasoning = {}, '', ''
|
pending, content, round_reasoning = {}, '', ''
|
||||||
document_preview_index = None
|
document_preview_index = None
|
||||||
document_preview_content = ''
|
document_preview_content = ''
|
||||||
@@ -6056,6 +6565,49 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
request = search_tool_choice_request(request)
|
request = search_tool_choice_request(request)
|
||||||
tool_call_requested = request.get('tool_choice') not in (None, 'auto', 'none')
|
tool_call_requested = request.get('tool_choice') not in (None, 'auto', 'none')
|
||||||
request = provider_compatible_tool_choice_request(request, model)
|
request = provider_compatible_tool_choice_request(request, model)
|
||||||
|
# Log the tool surface actually sent to the provider: the
|
||||||
|
# metrics `tool_schema_names` reports the pre-narrowing
|
||||||
|
# `offered` set, which hid the fact that a forced choice had
|
||||||
|
# shrunk the provider tools array to one schema.
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
"clean preview round %s sending %d tool(s): %s",
|
||||||
|
round_number,
|
||||||
|
len(request.get('tools') or []),
|
||||||
|
[
|
||||||
|
(schema.get('function') or {}).get('name')
|
||||||
|
for schema in (request.get('tools') or [])
|
||||||
|
][:40],
|
||||||
|
)
|
||||||
|
if round_number == 1 and _spec_direct_p is not None:
|
||||||
|
# The router decided to answer directly with high
|
||||||
|
# confidence.
|
||||||
|
if _spec_direct_empty_menu:
|
||||||
|
# Option B: an empty menu — send no tools at all, so
|
||||||
|
# nothing can be executed and no markup is recovered.
|
||||||
|
request['tools'] = []
|
||||||
|
request.pop('tool_choice', None)
|
||||||
|
tool_call_requested = False
|
||||||
|
# Keep the thinking router's decision; never let the
|
||||||
|
# no-tools progressive rule turn thinking on when the
|
||||||
|
# router said off.
|
||||||
|
if not (_spec_route_think or {}).get('think'):
|
||||||
|
ctk = dict(request.get('chat_template_kwargs') or {})
|
||||||
|
ctk['enable_thinking'] = False
|
||||||
|
request['chat_template_kwargs'] = ctk
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
'[spec-harness] direct answer: empty menu p=%s',
|
||||||
|
_spec_direct_p)
|
||||||
|
else:
|
||||||
|
# Default: keep the same tools list (prefix-cache
|
||||||
|
# friendly) but forbid a tool call this round. Thinking
|
||||||
|
# is left to the thinking router; offered stays
|
||||||
|
# non-empty so this does not switch progressive
|
||||||
|
# thinking on.
|
||||||
|
request['tool_choice'] = 'none'
|
||||||
|
tool_call_requested = False
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
'[spec-harness] direct answer: tool_choice none p=%s',
|
||||||
|
_spec_direct_p)
|
||||||
if active_editor_target and request.get('tools'):
|
if active_editor_target and request.get('tools'):
|
||||||
# Edits share mutable document state. Await a saved result
|
# Edits share mutable document state. Await a saved result
|
||||||
# before asking for another call; one call may still batch
|
# before asking for another call; one call may still batch
|
||||||
@@ -6144,6 +6696,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
and not progressive_thinking
|
and not progressive_thinking
|
||||||
and not source_lookup_pending
|
and not source_lookup_pending
|
||||||
and not tool_call_requested
|
and not tool_call_requested
|
||||||
|
and not _spec_direct_empty_menu
|
||||||
):
|
):
|
||||||
text_event = {'delta': text}
|
text_event = {'delta': text}
|
||||||
if replace_streamed_draft_on_finish and not streamed_round_text:
|
if replace_streamed_draft_on_finish and not streamed_round_text:
|
||||||
@@ -6197,6 +6750,13 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
if content and not prior_summary_answer and not source_lookup_pending:
|
if content and not prior_summary_answer and not source_lookup_pending:
|
||||||
yield event({'delta': content})
|
yield event({'delta': content})
|
||||||
streamed_round_text = True
|
streamed_round_text = True
|
||||||
|
if _spec_direct_empty_menu and pending:
|
||||||
|
# Option B: nothing is offered, so never execute a tool
|
||||||
|
# call the model still produced (native shape).
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
'[spec-harness] direct answer: dropped %d tool call(s)',
|
||||||
|
len(pending))
|
||||||
|
pending = {}
|
||||||
proposed = [pending[i] for i in sorted(pending)]
|
proposed = [pending[i] for i in sorted(pending)]
|
||||||
# A lead-in emitted before a tool call is live progress, not
|
# A lead-in emitted before a tool call is live progress, not
|
||||||
# part of the terminal answer. Replace that draft when the
|
# part of the terminal answer. Replace that draft when the
|
||||||
@@ -6204,7 +6764,9 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
if proposed and streamed_round_text:
|
if proposed and streamed_round_text:
|
||||||
replace_streamed_draft_on_finish = True
|
replace_streamed_draft_on_finish = True
|
||||||
unexecutable_dsml_completion = False
|
unexecutable_dsml_completion = False
|
||||||
if not proposed and 'DSML' in content:
|
if (not proposed and not _spec_direct_empty_menu
|
||||||
|
and _driver_round_kind != 'answer_directly'
|
||||||
|
and 'DSML' in content):
|
||||||
offered_by_canonical = {
|
offered_by_canonical = {
|
||||||
canonical(schema['function']['name']): schema['function']['name']
|
canonical(schema['function']['name']): schema['function']['name']
|
||||||
for schema in round_offered
|
for schema in round_offered
|
||||||
@@ -6252,6 +6814,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
unexecutable_dsml_completion = True
|
unexecutable_dsml_completion = True
|
||||||
|
if _spec_direct_empty_menu and content:
|
||||||
|
# Option B: show the answer text without any tool-call
|
||||||
|
# markup the model may still have written (nothing offered).
|
||||||
|
_stripped = re.sub(
|
||||||
|
r'<tool_call\b[^>]*>.*?</tool_call>', '',
|
||||||
|
content, flags=re.DOTALL | re.IGNORECASE).strip()
|
||||||
|
if _stripped != content.strip():
|
||||||
|
content = _stripped
|
||||||
|
replace_streamed_draft_on_finish = True
|
||||||
if source_lookup_pending and not proposed:
|
if source_lookup_pending and not proposed:
|
||||||
if source_answer_retries < 1 and round_number < round_limit:
|
if source_answer_retries < 1 and round_number < round_limit:
|
||||||
source_answer_retries += 1
|
source_answer_retries += 1
|
||||||
@@ -6276,6 +6847,37 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
)
|
)
|
||||||
if active_editor_target:
|
if active_editor_target:
|
||||||
proposed = drop_redundant_editor_noops(proposed)
|
proposed = drop_redundant_editor_noops(proposed)
|
||||||
|
# ODYSSEUS_SPEC_MODE=driver: Spec is the tool driver, so execute
|
||||||
|
# ONLY the call Spec picked this round. Drop (never execute) any
|
||||||
|
# other native call and any text/DSML-recovered call; when Spec
|
||||||
|
# said answer_directly, keep no calls and strip tool markup so
|
||||||
|
# the remaining text is the answer.
|
||||||
|
if _driver_round_kind == 'answer_directly':
|
||||||
|
if proposed:
|
||||||
|
for _c in proposed:
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
'[spec-driver] dropped off-pick call name=%s',
|
||||||
|
(_c.get('function') or {}).get('name'))
|
||||||
|
proposed = []
|
||||||
|
if content:
|
||||||
|
_stripped = strip_tool_blocks(content, skip_fenced=True).strip()
|
||||||
|
_stripped = re.sub(
|
||||||
|
r'<tool_call\b[^>]*>.*?</tool_call>', '',
|
||||||
|
_stripped, flags=re.DOTALL | re.IGNORECASE).strip()
|
||||||
|
if _stripped != content:
|
||||||
|
content = _stripped
|
||||||
|
replace_streamed_draft_on_finish = True
|
||||||
|
elif _driver_round_kind in ('tool', 'ask_user'):
|
||||||
|
_want = 'ask_user' if _driver_round_kind == 'ask_user' else _driver_round_pick
|
||||||
|
_keep = []
|
||||||
|
for _c in proposed:
|
||||||
|
_cn = (_c.get('function') or {}).get('name')
|
||||||
|
if canonical(str(_cn or '')) == canonical(str(_want or '')):
|
||||||
|
_keep.append(_c)
|
||||||
|
else:
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
'[spec-driver] dropped off-pick call name=%s', _cn)
|
||||||
|
proposed = _keep
|
||||||
if model_choice_experiment:
|
if model_choice_experiment:
|
||||||
for proposal in proposed:
|
for proposal in proposed:
|
||||||
yield event({'type': 'model_tool_proposal', 'round': round_number,
|
yield event({'type': 'model_tool_proposal', 'round': round_number,
|
||||||
@@ -6695,6 +7297,25 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
# that draft and persisted history with the evidence-backed result.
|
# that draft and persisted history with the evidence-backed result.
|
||||||
history.pop()
|
history.pop()
|
||||||
refusal = denied_response()
|
refusal = denied_response()
|
||||||
|
# A photo-editor turn with no canvas command actually executed
|
||||||
|
# (no browser editor attached) cannot claim the edit. Point the
|
||||||
|
# user at the editor instead of the generic no-op refusal.
|
||||||
|
if 'photo_editor' in (getattr(turn_contract, 'active_capabilities', ()) or ()):
|
||||||
|
touched_canvas = any(
|
||||||
|
str(e.get('tool') or '') in {
|
||||||
|
'run_editor_command', 'get_editor_screenshot',
|
||||||
|
'ai_edit_image', 'generate_image',
|
||||||
|
}
|
||||||
|
and not e.get('error') and e.get('exit_code', 0) == 0
|
||||||
|
for e in executions
|
||||||
|
)
|
||||||
|
if not touched_canvas:
|
||||||
|
refusal = (
|
||||||
|
'This photo is not open on the editor canvas, so I cannot '
|
||||||
|
'apply edits to it from chat. Open the attached image in the '
|
||||||
|
'editor (Quick Edit) and send the request there, and I will '
|
||||||
|
'apply it to the canvas.'
|
||||||
|
)
|
||||||
history.append({'role': 'assistant', 'content': refusal})
|
history.append({'role': 'assistant', 'content': refusal})
|
||||||
yield event({'type': 'final_response', 'content': refusal})
|
yield event({'type': 'final_response', 'content': refusal})
|
||||||
break
|
break
|
||||||
@@ -7948,6 +8569,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
output, user_text=latest_user,
|
output, user_text=latest_user,
|
||||||
max_items=contract_item_limit(turn_contract, 20),
|
max_items=contract_item_limit(turn_contract, 20),
|
||||||
)
|
)
|
||||||
|
followup = note_locator_followup(output, user_text=latest_user)
|
||||||
|
if followup:
|
||||||
|
if round_number < round_limit:
|
||||||
|
structured_terminal_response = ''
|
||||||
|
round_recovery_messages.append(followup)
|
||||||
|
else:
|
||||||
|
structured_terminal_response = (
|
||||||
|
'I found a note, but could not read its content in this turn.'
|
||||||
|
)
|
||||||
action = str(args.get('action') or '').replace('-', '_').casefold()
|
action = str(args.get('action') or '').replace('-', '_').casefold()
|
||||||
if (
|
if (
|
||||||
action in {'search', 'find'}
|
action in {'search', 'find'}
|
||||||
|
|||||||
@@ -92,6 +92,83 @@ def prune_multimodal_images(
|
|||||||
return pruned
|
return pruned
|
||||||
|
|
||||||
|
|
||||||
|
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:\s+attached)?:\s*([^\]]+)\]", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _image_turn_label(message: Dict) -> str:
|
||||||
|
"""Best-effort '[Image: name]' label for a message whose image parts are dropped."""
|
||||||
|
name = ""
|
||||||
|
content = message.get("content") if isinstance(message, dict) else None
|
||||||
|
if isinstance(content, list):
|
||||||
|
for item in content:
|
||||||
|
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
|
||||||
|
candidate = item.get("name") or (item.get("image_url") or {}).get("name")
|
||||||
|
if candidate:
|
||||||
|
name = str(candidate)
|
||||||
|
break
|
||||||
|
if not name:
|
||||||
|
text = _content_as_text(content)
|
||||||
|
match = _IMAGE_TURN_LABEL_RE.search(text)
|
||||||
|
if match:
|
||||||
|
name = match.group(1).strip()
|
||||||
|
return f"[Image: {name}]" if name else "[Image]"
|
||||||
|
|
||||||
|
|
||||||
|
def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict]:
|
||||||
|
"""Keep image parts only for the last `keep` messages that carry an image.
|
||||||
|
|
||||||
|
Older image blocks are dropped and replaced with a short ``[Image: name]``
|
||||||
|
text label so the model still knows a picture was shared (and its name)
|
||||||
|
without paying the image tokens on every later turn. Non-text, non-image
|
||||||
|
multimodal blocks (e.g. audio) are left untouched.
|
||||||
|
"""
|
||||||
|
limit = max(0, int(keep))
|
||||||
|
image_messages = [
|
||||||
|
index for index, message in enumerate(messages)
|
||||||
|
if isinstance(message, dict)
|
||||||
|
and isinstance(message.get("content"), list)
|
||||||
|
and any(
|
||||||
|
isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES
|
||||||
|
for item in message["content"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if len(image_messages) <= limit:
|
||||||
|
return list(messages)
|
||||||
|
selected = set(image_messages[len(image_messages) - limit:]) if limit else set()
|
||||||
|
|
||||||
|
trimmed: List[Dict] = []
|
||||||
|
for message_index, message in enumerate(messages):
|
||||||
|
if message_index in selected or not isinstance(message, dict):
|
||||||
|
trimmed.append(message)
|
||||||
|
continue
|
||||||
|
content = message.get("content")
|
||||||
|
if not isinstance(content, list):
|
||||||
|
trimmed.append(message)
|
||||||
|
continue
|
||||||
|
kept_blocks = [
|
||||||
|
item for item in content
|
||||||
|
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
||||||
|
]
|
||||||
|
if len(kept_blocks) == len(content):
|
||||||
|
trimmed.append(message)
|
||||||
|
continue
|
||||||
|
label = _image_turn_label(message)
|
||||||
|
# Attach the label to the trailing text block when possible so we do not
|
||||||
|
# introduce an extra content block; otherwise append a fresh text block.
|
||||||
|
labelled = False
|
||||||
|
for item in reversed(kept_blocks):
|
||||||
|
if isinstance(item, dict) and item.get("type") == "text":
|
||||||
|
item["text"] = (item.get("text") or "").rstrip() + f"\n{label}"
|
||||||
|
labelled = True
|
||||||
|
break
|
||||||
|
if not labelled:
|
||||||
|
kept_blocks.append({"type": "text", "text": label})
|
||||||
|
cloned = dict(message)
|
||||||
|
cloned["content"] = kept_blocks
|
||||||
|
trimmed.append(cloned)
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
COMPACT_THRESHOLD = 0.85 # Trigger compaction at 85% of context window
|
COMPACT_THRESHOLD = 0.85 # Trigger compaction at 85% of context window
|
||||||
SUMMARY_MAX_TOKENS = 1024
|
SUMMARY_MAX_TOKENS = 1024
|
||||||
SMALL_CONTEXT_LIMIT = 8192 # Models with context <= this get aggressive trimming
|
SMALL_CONTEXT_LIMIT = 8192 # Models with context <= this get aggressive trimming
|
||||||
@@ -106,7 +183,7 @@ def auto_compact_threshold_percent() -> int:
|
|||||||
return max(50, min(95, value))
|
return max(50, min(95, value))
|
||||||
|
|
||||||
# Cursor-style self-summarization prompt — produces structured, dense summaries
|
# Cursor-style self-summarization prompt — produces structured, dense summaries
|
||||||
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation to preserve context after compaction. Produce a structured summary that lets the conversation continue seamlessly.
|
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation with Ajax, the user's AI assistant, to preserve context after compaction. Produce a structured summary that lets Ajax continue seamlessly. Ajax is the assistant's name; Odysseus is the app name.
|
||||||
|
|
||||||
Use this format:
|
Use this format:
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Document processing: PDF/OCR extraction, text file handling, image VL analysis, user content building."""
|
"""Document processing: PDF/OCR extraction, text file handling, image VL analysis, user content building."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import base64
|
import base64
|
||||||
@@ -496,6 +497,49 @@ def analyze_image_with_vl(image_path: str, owner: str | None = None) -> str:
|
|||||||
return analyze_image_with_vl_result(image_path, owner=owner).get("text", "")
|
return analyze_image_with_vl_result(image_path, owner=owner).get("text", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _vision_max_side() -> int:
|
||||||
|
"""Longest-side cap for images inlined into the chat request (tokens)."""
|
||||||
|
try:
|
||||||
|
value = int(os.getenv("ODYSSEUS_VISION_MAX_SIDE", "1280") or 1280)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
value = 1280
|
||||||
|
return max(0, value)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_image_data_url(path: str, fallback_format: str) -> tuple[str, str]:
|
||||||
|
"""Return (data_url, image_format) for an image, downscaled if large.
|
||||||
|
|
||||||
|
Downscale the longest side to ODYSSEUS_VISION_MAX_SIDE (default 1280) and
|
||||||
|
re-encode as JPEG so the chat model does not pay full-resolution image
|
||||||
|
tokens. Small/undecodable images fall back to the original bytes so a
|
||||||
|
real attachment is never dropped.
|
||||||
|
"""
|
||||||
|
max_side = _vision_max_side()
|
||||||
|
try:
|
||||||
|
from PIL import Image # local import: keep module import cost low
|
||||||
|
with Image.open(path) as img:
|
||||||
|
img = img.convert("RGB") if img.mode not in ("RGB", "L") else img
|
||||||
|
width, height = img.size
|
||||||
|
if max_side and max(width, height) > max_side:
|
||||||
|
scale = max_side / float(max(width, height))
|
||||||
|
img = img.resize(
|
||||||
|
(max(1, round(width * scale)), max(1, round(height * scale))),
|
||||||
|
Image.LANCZOS,
|
||||||
|
)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format="JPEG", quality=85, optimize=True)
|
||||||
|
return (
|
||||||
|
"data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("utf-8"),
|
||||||
|
"jpeg",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"Image downscale failed for {path}: {exc}; sending original bytes")
|
||||||
|
with open(path, "rb") as image_file:
|
||||||
|
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
|
||||||
|
image_format = (fallback_format or "png").lower()
|
||||||
|
return f"data:image/{image_format};base64,{encoded_string}", image_format
|
||||||
|
|
||||||
|
|
||||||
def build_user_content(
|
def build_user_content(
|
||||||
text: str,
|
text: str,
|
||||||
attachment_ids: list[str] | None,
|
attachment_ids: list[str] | None,
|
||||||
@@ -542,15 +586,14 @@ def build_user_content(
|
|||||||
|
|
||||||
if upload_handler.is_image_file(display_name, mime):
|
if upload_handler.is_image_file(display_name, mime):
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as image_file:
|
|
||||||
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
|
|
||||||
# Extensionless uploads (e.g. a pasted screenshot) have no ext,
|
# Extensionless uploads (e.g. a pasted screenshot) have no ext,
|
||||||
# so fall back to the resolved MIME subtype rather than emitting
|
# so fall back to the resolved MIME subtype rather than emitting
|
||||||
# an invalid "data:image/;base64," with an empty subtype.
|
# an invalid "data:image/;base64," with an empty subtype.
|
||||||
image_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
|
fallback_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
|
||||||
|
data_url, image_format = encode_image_data_url(path, fallback_format)
|
||||||
content.append({
|
content.append({
|
||||||
"type": "image_url",
|
"type": "image_url",
|
||||||
"image_url": {"url": f"data:image/{image_format};base64,{encoded_string}"},
|
"image_url": {"url": data_url},
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to encode image {fid}: {e}")
|
logger.error(f"Failed to encode image {fid}: {e}")
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
"""Editor agent/planner model resolution.
|
||||||
|
|
||||||
|
The image-editor Quick Edit planner (`/api/editor/plan`, `/api/editor/agent`)
|
||||||
|
runs on an explicitly configured local vision model when one is reachable,
|
||||||
|
with the configured chat model as a fallback. This module owns:
|
||||||
|
|
||||||
|
* the local model base URLs / model id (settings-overridable),
|
||||||
|
* an ordered candidate list (per-request ``agent_model`` override, then the
|
||||||
|
local Qwen3.5-9B, then the hosted default/utility chat model),
|
||||||
|
* a vision fallback chain: local model WITH the canvas screenshot, else a
|
||||||
|
text-only call whose image is described by a separate VL caption model,
|
||||||
|
* a lightweight reachability probe for the configured local bases.
|
||||||
|
|
||||||
|
Everything is best-effort: when no local server is reachable the planner
|
||||||
|
transparently falls back to the previously-used hosted chat model, so the
|
||||||
|
editor agent never regresses to "no plan".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Local endpoints are opt-in through settings or ODYSSEUS_EDITOR_AGENT_BASES.
|
||||||
|
DEFAULT_LOCAL_BASES = []
|
||||||
|
DEFAULT_LOCAL_MODEL = "Qwen/Qwen3.5-9B"
|
||||||
|
|
||||||
|
# Vision caption model used when the editor-agent model cannot accept images.
|
||||||
|
DEFAULT_CAPTION_BASE = ""
|
||||||
|
DEFAULT_CAPTION_MODEL = "Qwen/Qwen3.5-9B"
|
||||||
|
|
||||||
|
# Served-model-id cache per base (populated on first use). The local servers 400
|
||||||
|
# with "Failed to load model" when asked for an id they do not serve (e.g. the
|
||||||
|
# client's default "Qwen3.5-9B-MLX-8bit", or a stale caption model), so we
|
||||||
|
# resolve the requested id against GET {base}/models and fall back to one that
|
||||||
|
# is actually served.
|
||||||
|
_MODELS_CACHE: Dict[str, set] = {}
|
||||||
|
|
||||||
|
_CAPTION_INSTRUCTION = (
|
||||||
|
"Describe this image in one or two sentences for a photo editor: the main "
|
||||||
|
"subject, the background/scene, lighting, dominant colours, and any obvious "
|
||||||
|
"flaws (blown highlights, colour cast, clutter, blur)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _setting(key: str, default: Any = "") -> Any:
|
||||||
|
try:
|
||||||
|
from src.settings import load_settings
|
||||||
|
|
||||||
|
value = load_settings().get(key)
|
||||||
|
return value if value not in (None, "") else default
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def local_bases() -> List[str]:
|
||||||
|
override = _setting("editor_agent_base_url")
|
||||||
|
if override:
|
||||||
|
return [str(override)]
|
||||||
|
env = os.getenv("ODYSSEUS_EDITOR_AGENT_BASES")
|
||||||
|
if env:
|
||||||
|
bases = [b.strip() for b in env.split(",") if b.strip()]
|
||||||
|
if bases:
|
||||||
|
return bases
|
||||||
|
return list(DEFAULT_LOCAL_BASES)
|
||||||
|
|
||||||
|
|
||||||
|
def local_model() -> str:
|
||||||
|
return str(_setting("editor_agent_model", DEFAULT_LOCAL_MODEL))
|
||||||
|
|
||||||
|
|
||||||
|
def caption_base() -> str:
|
||||||
|
return str(_setting("editor_caption_base_url", DEFAULT_CAPTION_BASE))
|
||||||
|
|
||||||
|
|
||||||
|
def caption_model() -> str:
|
||||||
|
return str(_setting("editor_caption_model", DEFAULT_CAPTION_MODEL))
|
||||||
|
|
||||||
|
|
||||||
|
# Per-request / per-plan context (contextvars keep this correct under the
|
||||||
|
# async event loop without sharing state between concurrent editor turns).
|
||||||
|
_override: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
||||||
|
"editor_agent_model_override", default=None
|
||||||
|
)
|
||||||
|
_last_model: contextvars.ContextVar[Optional[Tuple[str, str]]] = contextvars.ContextVar(
|
||||||
|
"editor_agent_last_model", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_override(value: Optional[str]) -> None:
|
||||||
|
_override.set(value or None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_override() -> Optional[str]:
|
||||||
|
return _override.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_last_model(model: str, source: str) -> None:
|
||||||
|
_last_model.set((model, source))
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_model() -> Optional[Tuple[str, str]]:
|
||||||
|
return _last_model.get()
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_url(base: str) -> str:
|
||||||
|
b = str(base or "").rstrip("/")
|
||||||
|
if b.endswith("/chat/completions"):
|
||||||
|
return b
|
||||||
|
return b + "/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
def _split_override(override: str) -> Tuple[str, str]:
|
||||||
|
"""Parse ``base::model`` (or a bare model id)."""
|
||||||
|
raw = str(override or "").strip()
|
||||||
|
if "::" in raw:
|
||||||
|
base, model = raw.split("::", 1)
|
||||||
|
return base.strip(), model.strip()
|
||||||
|
return "", raw
|
||||||
|
|
||||||
|
|
||||||
|
def _has_image(messages: List[dict]) -> bool:
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _first_image(messages: List[dict]) -> Optional[str]:
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||||
|
url = (part.get("image_url") or {}).get("url")
|
||||||
|
if isinstance(url, str):
|
||||||
|
return url
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_images(messages: List[dict]) -> List[dict]:
|
||||||
|
out: List[dict] = []
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
text = "\n".join(
|
||||||
|
str(part.get("text") or "")
|
||||||
|
for part in content
|
||||||
|
if isinstance(part, dict) and part.get("type") == "text"
|
||||||
|
)
|
||||||
|
out.append({"role": msg.get("role", "user"), "content": text})
|
||||||
|
else:
|
||||||
|
out.append({"role": msg.get("role", "user"), "content": content})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _with_caption(messages: List[dict], caption: str) -> List[dict]:
|
||||||
|
note = f"\n\n[Canvas screenshot caption: {caption}]"
|
||||||
|
out = [dict(m) for m in messages]
|
||||||
|
for msg in reversed(out):
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
msg["content"] = str(msg.get("content") or "") + note
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_chat(
|
||||||
|
base: str,
|
||||||
|
model: str,
|
||||||
|
messages: List[dict],
|
||||||
|
headers: Optional[dict],
|
||||||
|
timeout: float,
|
||||||
|
max_tokens: int = 1024,
|
||||||
|
response_format: Optional[dict] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
if not base or not model:
|
||||||
|
return None
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
# Constrain the planner to a strict JSON ops schema (structured outputs /
|
||||||
|
# grammar) so the model can only emit valid editor ops. Unknown servers
|
||||||
|
# ignore the field; the server-side schema validation is the real enforcer.
|
||||||
|
if isinstance(response_format, dict):
|
||||||
|
payload["response_format"] = response_format
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.post(_chat_url(base), json=payload, headers=headers or {})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.debug(
|
||||||
|
"editor agent chat %s -> %s (%s)", base, resp.status_code, resp.text[:200]
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("editor agent chat %s failed: %s", base, exc)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return data["choices"][0]["message"]["content"]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _served_models(base: str, timeout: float = 3.0) -> set:
|
||||||
|
"""Return the set of model ids a base serves (cached; empty on failure)."""
|
||||||
|
if not base:
|
||||||
|
return set()
|
||||||
|
cached = _MODELS_CACHE.get(base)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
ids: set = set()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.get(base.rstrip("/") + "/models")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
ids = {
|
||||||
|
str(m.get("id"))
|
||||||
|
for m in (resp.json().get("data") or [])
|
||||||
|
if isinstance(m, dict) and m.get("id")
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
ids = set()
|
||||||
|
_MODELS_CACHE[base] = ids
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_model(base: str, requested: str, fallback: str) -> str:
|
||||||
|
"""Return a model id ``base`` actually serves.
|
||||||
|
|
||||||
|
If the requested id is served, keep it; otherwise use ``fallback`` (when
|
||||||
|
served) or any served id. When the served list is unknown (server down),
|
||||||
|
keep the requested id (or ``fallback``).
|
||||||
|
"""
|
||||||
|
requested = str(requested or "").strip()
|
||||||
|
fallback = str(fallback or "").strip()
|
||||||
|
ids = await _served_models(base)
|
||||||
|
if ids:
|
||||||
|
if requested and requested in ids:
|
||||||
|
return requested
|
||||||
|
if fallback in ids:
|
||||||
|
return fallback
|
||||||
|
return sorted(ids)[0]
|
||||||
|
return requested or fallback
|
||||||
|
|
||||||
|
|
||||||
|
async def caption_image(data_url: str, owner: Optional[str] = None, timeout: float = 90.0) -> Optional[str]:
|
||||||
|
if not isinstance(data_url, str) or not data_url.startswith("data:image/"):
|
||||||
|
return None
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": _CAPTION_INSTRUCTION},
|
||||||
|
{"type": "image_url", "image_url": {"url": data_url}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
base = caption_base()
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
model = await _resolve_model(base, caption_model(), local_model())
|
||||||
|
return await _post_chat(base, model, messages, None, timeout, max_tokens=200)
|
||||||
|
|
||||||
|
|
||||||
|
_DONE_RE = re.compile(r"^\s*DONE\b", re.I)
|
||||||
|
_RETRY_RE = re.compile(r"RETRY:?\s*(.+)", re.I | re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_self_check(text: str) -> Dict[str, Any]:
|
||||||
|
s = str(text or "").strip()
|
||||||
|
if _DONE_RE.match(s):
|
||||||
|
return {"done": True, "issues": [], "retry_hint": "", "text": s}
|
||||||
|
m = _RETRY_RE.search(s)
|
||||||
|
if m or re.search(r"\bretry\b", s, re.I):
|
||||||
|
hint = (m.group(1).strip() if m else s) or s
|
||||||
|
return {"done": False, "issues": [hint], "retry_hint": hint, "text": s}
|
||||||
|
return {"done": True, "issues": [], "retry_hint": "", "text": s}
|
||||||
|
|
||||||
|
|
||||||
|
async def self_check(
|
||||||
|
before: Any,
|
||||||
|
after: Any,
|
||||||
|
prompt: str,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Same-origin VL self-check: compare BEFORE vs AFTER against the intent.
|
||||||
|
|
||||||
|
The browser cannot call the local VL server directly (it sends no CORS
|
||||||
|
headers), so ``/api/editor/self-check`` proxies here. Returns
|
||||||
|
``{done, issues, retry_hint, text}``; on any failure (bad input, the model
|
||||||
|
is unreachable, or a timeout) it degrades to ``done=True`` with
|
||||||
|
``reason="unavailable"`` so the client never gets stuck retrying.
|
||||||
|
"""
|
||||||
|
if not (
|
||||||
|
isinstance(before, str) and before.startswith("data:image/")
|
||||||
|
and isinstance(after, str) and after.startswith("data:image/")
|
||||||
|
):
|
||||||
|
return {"done": True, "issues": [], "retry_hint": "",
|
||||||
|
"reason": "unavailable", "text": "DONE"}
|
||||||
|
bases = local_bases() or list(DEFAULT_LOCAL_BASES)
|
||||||
|
if not bases:
|
||||||
|
return {"done": True, "issues": [], "retry_hint": "",
|
||||||
|
"reason": "unavailable", "text": "DONE"}
|
||||||
|
base = bases[0]
|
||||||
|
mdl = await _resolve_model(base, str(model or local_model()), local_model())
|
||||||
|
messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": str(prompt or "")},
|
||||||
|
{"type": "image_url", "image_url": {"url": before}},
|
||||||
|
{"type": "image_url", "image_url": {"url": after}},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
text = await _post_chat(base, mdl, messages, None, timeout, max_tokens=140)
|
||||||
|
if not text:
|
||||||
|
return {"done": True, "issues": [], "retry_hint": "",
|
||||||
|
"reason": "unavailable", "text": "DONE"}
|
||||||
|
out = _parse_self_check(text)
|
||||||
|
out["model"] = mdl
|
||||||
|
out["source"] = "local"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
||||||
|
cands: List[Dict[str, Any]] = []
|
||||||
|
if override:
|
||||||
|
base, model = _split_override(override)
|
||||||
|
if model:
|
||||||
|
cands.append(
|
||||||
|
{"base": base or local_bases()[0], "model": model, "headers": None, "source": "override"}
|
||||||
|
)
|
||||||
|
for base in local_bases():
|
||||||
|
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
||||||
|
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:
|
||||||
|
cands.append({"base": url, "model": model, "headers": headers, "source": "hosted"})
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return cands
|
||||||
|
|
||||||
|
|
||||||
|
async def plan_with_editor_agent(
|
||||||
|
messages: List[dict],
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
override: Optional[str] = None,
|
||||||
|
timeout: float = 90.0,
|
||||||
|
response_format: Optional[dict] = None,
|
||||||
|
) -> Optional[Tuple[str, str, str]]:
|
||||||
|
"""Return (raw_plan_text, model_used, source) or None.
|
||||||
|
|
||||||
|
Tries the local model WITH the screenshot, then the same model text-only
|
||||||
|
with a VL caption of the screenshot, then the hosted chat model.
|
||||||
|
|
||||||
|
``response_format`` (a JSON-schema structured-output constraint) is sent
|
||||||
|
ONLY to the local editor-agent bases, so a hosted fallback model that does
|
||||||
|
not support the field never rejects the request.
|
||||||
|
"""
|
||||||
|
has_img = _has_image(messages)
|
||||||
|
text_messages = _strip_images(messages) if has_img else messages
|
||||||
|
image = _first_image(messages) if has_img else None
|
||||||
|
caption: Optional[str] = None
|
||||||
|
_local = {str(b).rstrip("/") for b in local_bases()}
|
||||||
|
for cand in _candidates(owner, override):
|
||||||
|
base, model = cand["base"], cand["model"]
|
||||||
|
headers = cand.get("headers")
|
||||||
|
is_local = cand["source"] == "local" or str(base).rstrip("/") in _local
|
||||||
|
rf = response_format if (response_format and is_local) else None
|
||||||
|
extra = {"response_format": rf} if rf else {}
|
||||||
|
if has_img:
|
||||||
|
raw = await _post_chat(base, model, messages, headers, timeout, **extra)
|
||||||
|
if raw:
|
||||||
|
return raw, model, cand["source"]
|
||||||
|
if caption is None and image:
|
||||||
|
caption = await caption_image(image, owner)
|
||||||
|
if caption:
|
||||||
|
raw = await _post_chat(
|
||||||
|
base, model, _with_caption(text_messages, caption), headers, timeout, **extra,
|
||||||
|
)
|
||||||
|
if raw:
|
||||||
|
return raw, model, cand["source"] + "+caption"
|
||||||
|
else:
|
||||||
|
raw = await _post_chat(
|
||||||
|
base, model, text_messages, headers, timeout, **extra
|
||||||
|
)
|
||||||
|
if raw:
|
||||||
|
return raw, model, cand["source"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Models available to the editor agent, for the model picker.
|
||||||
|
|
||||||
|
Lists the local Qwen3.5-9B (one entry per reachable base) plus the hosted
|
||||||
|
default/utility chat model that the planner falls back to.
|
||||||
|
"""
|
||||||
|
models: List[Dict[str, Any]] = []
|
||||||
|
seen: set = set()
|
||||||
|
for base in local_bases():
|
||||||
|
model = local_model()
|
||||||
|
key = ("local", base, model)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
models.append({
|
||||||
|
"id": model,
|
||||||
|
"label": f"{model} · local",
|
||||||
|
"source": "local",
|
||||||
|
"vision": True,
|
||||||
|
"base_url": base,
|
||||||
|
})
|
||||||
|
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:
|
||||||
|
key = ("hosted", model)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
models.append({
|
||||||
|
"id": model,
|
||||||
|
"label": model,
|
||||||
|
"source": "hosted",
|
||||||
|
"vision": None,
|
||||||
|
"base_url": url,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reachability probe (best effort, non-blocking).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PROBE: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def probe_models(base: str, timeout: float = 3.0) -> bool:
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
resp = client.get(str(base).rstrip("/") + "/models")
|
||||||
|
return resp.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_worker() -> None:
|
||||||
|
for base in local_bases():
|
||||||
|
ok = probe_models(base)
|
||||||
|
_PROBE[base] = ok
|
||||||
|
logger.info("editor agent local base %s reachable=%s", base, ok)
|
||||||
|
cap = caption_base()
|
||||||
|
if cap:
|
||||||
|
_PROBE[cap] = probe_models(cap)
|
||||||
|
logger.info("editor agent caption base %s reachable=%s", cap, _PROBE[cap])
|
||||||
|
|
||||||
|
|
||||||
|
def startup_probe() -> None:
|
||||||
|
"""Kick off a background reachability probe (never blocks startup)."""
|
||||||
|
try:
|
||||||
|
threading.Thread(target=_probe_worker, name="editor-agent-probe", daemon=True).start()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("editor agent probe thread failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_status() -> Dict[str, bool]:
|
||||||
|
return dict(_PROBE)
|
||||||
File diff suppressed because it is too large
Load Diff
+113
-6
@@ -2,7 +2,8 @@
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import copy
|
import copy
|
||||||
from dataclasses import dataclass
|
import re
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -19,7 +20,7 @@ _DEPENDENCIES = {
|
|||||||
'web': {'web_search', 'web_fetch', 'private_browser'},
|
'web': {'web_search', 'web_fetch', 'private_browser'},
|
||||||
'email': {'list_email_accounts', 'list_emails', 'search_emails', 'read_email',
|
'email': {'list_email_accounts', 'list_emails', 'search_emails', 'read_email',
|
||||||
'download_attachment'},
|
'download_attachment'},
|
||||||
'contacts': {'resolve_contact'},
|
'contacts': {'resolve_contact', 'manage_contact'},
|
||||||
'documents': {'search_documents', 'read_document'},
|
'documents': {'search_documents', 'read_document'},
|
||||||
}
|
}
|
||||||
_DRAFT_TOOLS = {'ask_user', 'update_plan', 'draft_email', 'draft_email_reply',
|
_DRAFT_TOOLS = {'ask_user', 'update_plan', 'draft_email', 'draft_email_reply',
|
||||||
@@ -116,8 +117,9 @@ def parse_email_task_intent(value):
|
|||||||
|
|
||||||
def scope_email_tools(schemas, intent, *, active_editor=False):
|
def scope_email_tools(schemas, intent, *, active_editor=False):
|
||||||
if intent.operation == 'read' and intent.dependencies:
|
if intent.operation == 'read' and intent.dependencies:
|
||||||
allowed = set().union(*(_DEPENDENCIES[d] for d in intent.dependencies))
|
allowed = {tool for dependency in intent.dependencies
|
||||||
if active_editor:
|
for tool in _DEPENDENCIES[dependency]}
|
||||||
|
if active_editor and intent.dependencies:
|
||||||
allowed.update(_OPEN_EDITOR_TOOLS)
|
allowed.update(_OPEN_EDITOR_TOOLS)
|
||||||
if intent.needs_clarification:
|
if intent.needs_clarification:
|
||||||
allowed.add('ask_user')
|
allowed.add('ask_user')
|
||||||
@@ -126,6 +128,9 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
|
|||||||
if intent.operation not in {'draft', 'revise'}:
|
if intent.operation not in {'draft', 'revise'}:
|
||||||
return list(schemas)
|
return list(schemas)
|
||||||
allowed = _DRAFT_TOOLS.union(*(_DEPENDENCIES[d] for d in intent.dependencies))
|
allowed = _DRAFT_TOOLS.union(*(_DEPENDENCIES[d] for d in intent.dependencies))
|
||||||
|
# manage_contact is a read-only runtime source for lookups, not a
|
||||||
|
# composition tool. Drafts still resolve recipients through resolve_contact.
|
||||||
|
allowed.discard('manage_contact')
|
||||||
if active_editor:
|
if active_editor:
|
||||||
allowed.update(_OPEN_EDITOR_TOOLS)
|
allowed.update(_OPEN_EDITOR_TOOLS)
|
||||||
if not intent.needs_clarification:
|
if not intent.needs_clarification:
|
||||||
@@ -145,6 +150,62 @@ def scope_email_tools(schemas, intent, *, active_editor=False):
|
|||||||
# as though it were the whole task. This byte budget is deliberately conservative.
|
# as though it were the whole task. This byte budget is deliberately conservative.
|
||||||
CLASSIFIER_CONTEXT_BYTES = 24000
|
CLASSIFIER_CONTEXT_BYTES = 24000
|
||||||
|
|
||||||
|
# The model can produce a structurally valid classification while forgetting
|
||||||
|
# the source or mistaking a list of message headers for a request for bodies.
|
||||||
|
_OWN_MAILBOX = re.compile(
|
||||||
|
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||||
|
r'(?:e-?mails?|mail|inbox|mailbox)\b', re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_MAILBOX_HEADER_LIST = re.compile(
|
||||||
|
r'^\s*(?:list|show|display|what(?:\'s| are))\s+(?:me\s+)?(?:my|our)\s+'
|
||||||
|
r'(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||||
|
r'(?:e-?mails|messages)\s*'
|
||||||
|
r'(?:with\s+(?:(?:sender|subject|date|time|recipient|status)\s*'
|
||||||
|
r'(?:(?:,|and)\s*)?)+)?[?.!]*\s*$', re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_EMAIL_HEADER_FIELDS = re.compile(r'\b(?:subjects?|senders?|dates?)\b', re.IGNORECASE)
|
||||||
|
_EMAIL_HEADER_FOLLOWUP = re.compile(r'\b(?:those|these|them|their|same)\b', re.IGNORECASE)
|
||||||
|
_READ_REQUEST = re.compile(r'^\s*(?:please\s+)?(?:show|list|display|find|search|read|what|how many|are there|do i have)\b', re.IGNORECASE)
|
||||||
|
_EMAIL_COUNT = re.compile(r'^\s*how many\s+(?:(?:unread|read|new)\s+)?(?:e-?mails|messages)\b',
|
||||||
|
re.IGNORECASE)
|
||||||
|
_EMAIL_BODY_QUERY = re.compile(
|
||||||
|
r'\b(?:mention(?:s|ed|ing)?|body|contents?|amount|owe|says?|said|when|where|why|'
|
||||||
|
r'starts?|deadline|attachments?)\b|\bhow much\b|\bwhat time\b',
|
||||||
|
re.IGNORECASE)
|
||||||
|
_CONTACT_EMAIL_FIELD = re.compile(
|
||||||
|
r'\bcontacts?\b[^.!?\n]{0,100}\bemail\b', re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_MAILBOX_SOURCE = re.compile(
|
||||||
|
r'\b(?:my|our)\s+(?:(?:last|latest|recent|new|unread|read|first|most|\d+)\s+){0,5}'
|
||||||
|
r'(?:e-?mails?|inbox|mailbox)\b|\b(?:inbox|mailbox|email\s+history)\b|'
|
||||||
|
r'\b(?:e-?mails?|messages?)\s+(?:from|to|sent|received)\b|'
|
||||||
|
r'\bemailed?\s+(?:me|us)\b', re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
EMAIL_TASK_RESPONSE_FORMAT = {
|
||||||
|
'type': 'json_schema',
|
||||||
|
'json_schema': {
|
||||||
|
'name': 'email_task_intent',
|
||||||
|
'strict': True,
|
||||||
|
'schema': {
|
||||||
|
'type': 'object',
|
||||||
|
'additionalProperties': False,
|
||||||
|
'properties': {
|
||||||
|
'operation': {'type': 'string', 'enum': ['draft', 'revise', 'read', 'send', 'other']},
|
||||||
|
'requires_content': {'type': 'boolean'},
|
||||||
|
'dependencies': {'type': 'array', 'items': {
|
||||||
|
'type': 'string', 'enum': list(_DEPENDENCIES),
|
||||||
|
}},
|
||||||
|
'destination': {'type': 'string', 'enum': ['chat', 'mailbox']},
|
||||||
|
'needs_clarification': {'type': 'boolean'},
|
||||||
|
'summary': {'type': 'string'},
|
||||||
|
},
|
||||||
|
'required': ['operation', 'requires_content', 'dependencies', 'destination',
|
||||||
|
'needs_clarification', 'summary'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
||||||
supplied_context=None, accounting=None):
|
supplied_context=None, accounting=None):
|
||||||
@@ -152,6 +213,8 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
|||||||
# text from multimodal messages, so an attached image cannot hide the latest
|
# text from multimodal messages, so an attached image cannot hide the latest
|
||||||
# instruction and leave us classifying an earlier task instead.
|
# instruction and leave us classifying an earlier task instead.
|
||||||
dialogue = []
|
dialogue = []
|
||||||
|
latest_user_text = ''
|
||||||
|
prior_user_text = ''
|
||||||
for row in history:
|
for row in history:
|
||||||
if row.get('role') not in {'user', 'assistant'} or row.get('_harness_control'):
|
if row.get('role') not in {'user', 'assistant'} or row.get('_harness_control'):
|
||||||
continue
|
continue
|
||||||
@@ -166,6 +229,9 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
|||||||
and isinstance(block.get('text'), str))
|
and isinstance(block.get('text'), str))
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
dialogue.append({'role': row['role'], 'content': content})
|
dialogue.append({'role': row['role'], 'content': content})
|
||||||
|
if row['role'] == 'user':
|
||||||
|
prior_user_text = latest_user_text
|
||||||
|
latest_user_text = content
|
||||||
payload = json.dumps({'dialogue': dialogue, 'supplied_context': supplied_context},
|
payload = json.dumps({'dialogue': dialogue, 'supplied_context': supplied_context},
|
||||||
ensure_ascii=False)
|
ensure_ascii=False)
|
||||||
if len(payload.encode('utf-8')) > CLASSIFIER_CONTEXT_BYTES:
|
if len(payload.encode('utf-8')) > CLASSIFIER_CONTEXT_BYTES:
|
||||||
@@ -174,7 +240,7 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
|||||||
response = await client.post(endpoint_url, headers=headers, timeout=20, json={
|
response = await client.post(endpoint_url, headers=headers, timeout=20, json={
|
||||||
'model': model, 'stream': False, 'temperature': 0, 'max_tokens': 500,
|
'model': model, 'stream': False, 'temperature': 0, 'max_tokens': 500,
|
||||||
'chat_template_kwargs': {'enable_thinking': False},
|
'chat_template_kwargs': {'enable_thinking': False},
|
||||||
'response_format': {'type': 'json_object'},
|
'response_format': EMAIL_TASK_RESPONSE_FORMAT,
|
||||||
'messages': [{'role': 'system', 'content': (
|
'messages': [{'role': 'system', 'content': (
|
||||||
'Classify the current conversational task. Return JSON only with operation '
|
'Classify the current conversational task. Return JSON only with operation '
|
||||||
'(draft, revise, read, send, other), requires_content (boolean), dependencies (array containing only web, '
|
'(draft, revise, read, send, other), requires_content (boolean), dependencies (array containing only web, '
|
||||||
@@ -250,6 +316,47 @@ async def classify_email_task(client, *, endpoint_url, headers, model, history,
|
|||||||
'response_time': round(time.monotonic() - started, 3),
|
'response_time': round(time.monotonic() - started, 3),
|
||||||
})
|
})
|
||||||
try:
|
try:
|
||||||
return parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
|
intent = parse_email_task_intent(json.loads(body['choices'][0]['message']['content']))
|
||||||
|
if intent.operation == 'read' and not intent.needs_clarification:
|
||||||
|
# In a contact-record question, "email" can name an address field
|
||||||
|
# rather than the mailbox as a source. Keep both dependencies when
|
||||||
|
# the request also names a mailbox/history source.
|
||||||
|
contact_email_field = bool(
|
||||||
|
_CONTACT_EMAIL_FIELD.search(latest_user_text)
|
||||||
|
and not _MAILBOX_SOURCE.search(latest_user_text)
|
||||||
|
)
|
||||||
|
if contact_email_field:
|
||||||
|
intent = replace(intent, dependencies=tuple(dict.fromkeys(
|
||||||
|
(*[d for d in intent.dependencies if d != 'email'], 'contacts')
|
||||||
|
)))
|
||||||
|
# Reuse the product's existing source classifier rather than a
|
||||||
|
# second vocabulary of mailbox phrases. The classifier has a known
|
||||||
|
# gap for possessive single-message questions, so retain that
|
||||||
|
# narrow fallback only when it found no competing source family.
|
||||||
|
from src.turn_contract import requested_capabilities
|
||||||
|
capabilities = requested_capabilities(latest_user_text)
|
||||||
|
# A header-only reference can inherit the immediately preceding
|
||||||
|
# mailbox read, but an intervening request or a competing source
|
||||||
|
# breaks that link. Never infer a read from an earlier write.
|
||||||
|
header_followup = (
|
||||||
|
not capabilities
|
||||||
|
and bool(_EMAIL_HEADER_FIELDS.search(latest_user_text))
|
||||||
|
and bool(_EMAIL_HEADER_FOLLOWUP.search(latest_user_text))
|
||||||
|
and bool(_READ_REQUEST.match(prior_user_text))
|
||||||
|
and requested_capabilities(prior_user_text) == {'email'}
|
||||||
|
)
|
||||||
|
mailbox_read = not contact_email_field and (capabilities == {'email'} or (
|
||||||
|
not capabilities and bool(_OWN_MAILBOX.search(latest_user_text))
|
||||||
|
) or header_followup)
|
||||||
|
else:
|
||||||
|
mailbox_read = False
|
||||||
|
if mailbox_read:
|
||||||
|
intent = replace(intent, dependencies=tuple(dict.fromkeys((*intent.dependencies, 'email'))))
|
||||||
|
if (not _EMAIL_BODY_QUERY.search(latest_user_text)
|
||||||
|
and (_MAILBOX_HEADER_LIST.fullmatch(latest_user_text)
|
||||||
|
or _EMAIL_HEADER_FIELDS.search(latest_user_text)
|
||||||
|
or _EMAIL_COUNT.match(latest_user_text))):
|
||||||
|
intent = replace(intent, requires_content=False)
|
||||||
|
return intent
|
||||||
except (KeyError, IndexError, TypeError) as exc:
|
except (KeyError, IndexError, TypeError) as exc:
|
||||||
raise ValueError('Invalid classifier response') from exc
|
raise ValueError('Invalid classifier response') from exc
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ def _row(index: int, message: dict[str, Any]) -> dict[str, Any]:
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
"uid": str(message.get("uid") or _uid(index)),
|
"uid": str(message.get("uid") or _uid(index)),
|
||||||
|
"message_id": str(message.get("message_id") or ""),
|
||||||
"account": str(message.get("account") or "fixture"),
|
"account": str(message.get("account") or "fixture"),
|
||||||
"account_email": str(message.get("account_email") or message.get("to") or ""),
|
"account_email": str(message.get("account_email") or message.get("to") or ""),
|
||||||
"account_id": str(message.get("account_id") or "fixture"),
|
"account_id": str(message.get("account_id") or "fixture"),
|
||||||
@@ -78,6 +79,16 @@ def _row(index: int, message: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"folder": str(message.get("folder") or "INBOX"),
|
"folder": str(message.get("folder") or "INBOX"),
|
||||||
"attachments": attachments,
|
"attachments": attachments,
|
||||||
"has_attachments": bool(attachments),
|
"has_attachments": bool(attachments),
|
||||||
|
"summary": str(message.get("summary") or message.get("body") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _account_matches(message: dict[str, Any], account: str | None) -> bool:
|
||||||
|
selector = str(account or "").strip().casefold()
|
||||||
|
return not selector or selector in {
|
||||||
|
str(message.get("account") or "fixture").strip().casefold(),
|
||||||
|
str(message.get("account_email") or message.get("to") or "").strip().casefold(),
|
||||||
|
str(message.get("account_id") or "fixture").strip().casefold(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +119,12 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
|
|
||||||
bare = tool.removeprefix("mcp__email__")
|
bare = tool.removeprefix("mcp__email__")
|
||||||
if bare == "list_email_accounts":
|
if bare == "list_email_accounts":
|
||||||
|
accounts = {}
|
||||||
|
for index, message in enumerate(messages):
|
||||||
|
row = _row(index, message)
|
||||||
|
identity = (row["account_id"], row["account"], row["account_email"])
|
||||||
|
accounts.setdefault(identity, {"id": row["account_id"], "name": row["account"],
|
||||||
|
"email": row["account_email"], "default": not accounts})
|
||||||
return {
|
return {
|
||||||
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
||||||
"output": "Fixture mailbox (default)",
|
"output": "Fixture mailbox (default)",
|
||||||
@@ -159,6 +176,7 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
|
|
||||||
if bare == "read_email":
|
if bare == "read_email":
|
||||||
uid = str(args.get("uid") or "")
|
uid = str(args.get("uid") or "")
|
||||||
|
message_id = str(args.get("message_id") or "")
|
||||||
folder = str(args.get("folder") or "INBOX")
|
folder = str(args.get("folder") or "INBOX")
|
||||||
try:
|
try:
|
||||||
index = int(uid) - 1
|
index = int(uid) - 1
|
||||||
@@ -185,6 +203,7 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
all_messages = _messages()
|
all_messages = _messages()
|
||||||
owner_value = str(owner or "").strip()
|
owner_value = str(owner or "").strip()
|
||||||
visible_index = -1
|
visible_index = -1
|
||||||
|
matches = []
|
||||||
for original in all_messages:
|
for original in all_messages:
|
||||||
if owner_value and original.get("owner") and original.get("owner") != owner_value:
|
if owner_value and original.get("owner") and original.get("owner") != owner_value:
|
||||||
continue
|
continue
|
||||||
@@ -193,6 +212,13 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
continue
|
continue
|
||||||
if not _folder_matches(original.get("folder"), folder):
|
if not _folder_matches(original.get("folder"), folder):
|
||||||
continue
|
continue
|
||||||
|
if not _account_matches(original, args.get("account")):
|
||||||
|
continue
|
||||||
|
matches.append(original)
|
||||||
|
if len(matches) > 1:
|
||||||
|
return {"error": "Email identity is ambiguous; specify its account and folder.", "exit_code": 1}
|
||||||
|
if matches:
|
||||||
|
original = matches[0]
|
||||||
if bare == "archive_email":
|
if bare == "archive_email":
|
||||||
original["folder"] = "Archive"
|
original["folder"] = "Archive"
|
||||||
action = "Archived"
|
action = "Archived"
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Thin client for the local jeff-serve decision endpoint (POST /v1/systemone).
|
||||||
|
|
||||||
|
Trained decision adapters (triage, spam, guard-email, ...) answer a situation
|
||||||
|
with calibrated probabilities over named options in a single forward pass, with
|
||||||
|
no generated text to parse. Background jobs can prefer an adapter and fall back
|
||||||
|
to the normal LLM candidate chain whenever this client returns None.
|
||||||
|
|
||||||
|
Every call fails open (returns None) and is disabled per job unless the job's
|
||||||
|
flag is set, so importing/enabling this never changes behaviour on its own.
|
||||||
|
|
||||||
|
Env:
|
||||||
|
ODYSSEUS_JEFF_URL base URL of jeff-serve (default http://127.0.0.1:8765)
|
||||||
|
ODYSSEUS_JEFF_TIMEOUT per-request timeout in seconds (default 4.0)
|
||||||
|
ODYSSEUS_JEFF_<JOB> per-job enable flag, e.g. ODYSSEUS_JEFF_EMAIL_URGENCY=1
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_URL = "http://127.0.0.1:8765"
|
||||||
|
_FALSEY = ("", "0", "false", "off", "no")
|
||||||
|
|
||||||
|
|
||||||
|
def jeff_url() -> str:
|
||||||
|
"""Base URL of the local jeff-serve (no trailing slash)."""
|
||||||
|
return os.getenv("ODYSSEUS_JEFF_URL", _DEFAULT_URL).strip().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def jeff_job_enabled(job: str) -> bool:
|
||||||
|
"""True when ODYSSEUS_JEFF_<JOB> is set to a truthy value. Default off."""
|
||||||
|
slug = "".join(c if (c.isalnum() or c == "_") else "_" for c in (job or "")).upper()
|
||||||
|
return os.getenv("ODYSSEUS_JEFF_" + slug, "0").strip().lower() not in _FALSEY
|
||||||
|
|
||||||
|
|
||||||
|
def jeff_timeout(default: float = 4.0) -> float:
|
||||||
|
try:
|
||||||
|
return float(os.getenv("ODYSSEUS_JEFF_TIMEOUT", str(default)))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
async def jeff_systemone(
|
||||||
|
model: str,
|
||||||
|
state: Dict[str, Any],
|
||||||
|
questions: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
timeout: Optional[float] = None,
|
||||||
|
orders: int = 1,
|
||||||
|
client: Optional[httpx.AsyncClient] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""POST a decision request and return the ``answers`` dict, or None on error.
|
||||||
|
|
||||||
|
Each question is scored in its own forward pass, so send only questions the
|
||||||
|
adapter was trained on. Raises are swallowed and logged (fail open).
|
||||||
|
"""
|
||||||
|
url = jeff_url()
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
payload: Dict[str, Any] = {"model": model, "state": state, "questions": questions}
|
||||||
|
if orders and orders != 1:
|
||||||
|
payload["orders"] = orders
|
||||||
|
to = timeout if timeout is not None else jeff_timeout()
|
||||||
|
try:
|
||||||
|
if client is None:
|
||||||
|
async with httpx.AsyncClient(timeout=to) as _c:
|
||||||
|
resp = await _c.post(url + "/v1/systemone", json=payload)
|
||||||
|
else:
|
||||||
|
resp = await client.post(url + "/v1/systemone", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
answers = (resp.json() or {}).get("answers")
|
||||||
|
return answers if isinstance(answers, dict) else None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("jeff decision (%s) failed: %s", model, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def jeff_available(timeout: float = 1.5) -> bool:
|
||||||
|
"""Best-effort health probe for the local jeff-serve."""
|
||||||
|
url = jeff_url()
|
||||||
|
if not url:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as _c:
|
||||||
|
resp = await _c.get(url + "/health")
|
||||||
|
return resp.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def score_value(answer: Optional[Dict[str, Any]]) -> Optional[float]:
|
||||||
|
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
|
||||||
|
if not isinstance(answer, dict):
|
||||||
|
return None
|
||||||
|
if answer.get("type") == "score":
|
||||||
|
try:
|
||||||
|
return float(answer.get("score"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if answer.get("type") == "choice":
|
||||||
|
probs = answer.get("probabilities") or {}
|
||||||
|
try:
|
||||||
|
return float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
"""The winning key of a jeff ``choice`` answer, or None."""
|
||||||
|
if isinstance(answer, dict) and answer.get("type") == "choice":
|
||||||
|
c = answer.get("choice")
|
||||||
|
return str(c) if c is not None else None
|
||||||
|
return None
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Pre-Wave-3 private_browser schema (agent-browser wrapper), used when ODYSSEUS_PRIVATE_BROWSER_LEGACY is on."""
|
||||||
|
|
||||||
|
PRIVATE_BROWSER_SCHEMA = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "private_browser",
|
||||||
|
"description": "Private browser automation through Odysseus' agent-browser wrapper. After open, snapshot the page and interact with returned element refs such as @e12; click/fill target is a selector or element ref, never guessed visible text. Prefer one batch for known consecutive steps, such as open plus snapshot. Use only when a specific page needs JavaScript, login/session state, interaction, or rendered DOM. For open-ended search use web_search; for reading a normal URL use web_fetch.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {"type": "string", "enum": ["open", "read", "snapshot", "find", "evaluate", "click", "fill", "press", "scroll", "wait", "screenshot", "close", "batch"]},
|
||||||
|
"url": {"type": "string", "description": "Required URL for open; optional URL for read (omit to read the current page)"},
|
||||||
|
"selector": {"type": "string", "description": "Element ref or selector for read/click/fill/wait"},
|
||||||
|
"target": {"type": "string", "description": "Element ref returned by snapshot (preferred, e.g. @e12) or CSS selector for read/click/fill/wait; never a guessed visible label; top or bottom for scroll"},
|
||||||
|
"key": {"type": "string", "description": "Key name for press action, e.g. Enter"},
|
||||||
|
"direction": {"type": "string", "enum": ["up", "down", "left", "right"], "description": "Direction for scroll action"},
|
||||||
|
"amount": {"type": "integer", "minimum": 1, "description": "Optional scroll distance in pixels; default 300"},
|
||||||
|
"text": {"type": "string", "description": "Text for fill action"},
|
||||||
|
"value": {"type": "string", "description": "Alternative text/value for fill action"},
|
||||||
|
"find": {"type": "string", "description": "Visible text to locate for find action"},
|
||||||
|
"script": {"type": "string", "description": "JavaScript expression for evaluate action"},
|
||||||
|
"path": {"type": "string", "description": "Optional output path for screenshot"},
|
||||||
|
"commands": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Non-empty batch commands as arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]. Do not send an empty batch; use action=snapshot for current page state.",
|
||||||
|
"items": {
|
||||||
|
"oneOf": [
|
||||||
|
{"type": "array", "items": {"type": "string"}},
|
||||||
|
{"type": "object"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"timeout_ms": {"type": "integer", "description": "Optional operation timeout, max 120000; for action=wait without a selector, this is the wait duration"}
|
||||||
|
},
|
||||||
|
"required": ["action"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,8 @@ def untrusted_context_message(
|
|||||||
"""
|
"""
|
||||||
safe_label = _sanitize_label(label)
|
safe_label = _sanitize_label(label)
|
||||||
text = "" if content is None else str(content)
|
text = "" if content is None else str(content)
|
||||||
|
import logging as _lg
|
||||||
|
_lg.getLogger("src.prompt_security").info("[spec-debug] untrusted block label=%r content_chars=%d", safe_label[:80], len(text))
|
||||||
text = _escape_guard_markers(text)
|
text = _escape_guard_markers(text)
|
||||||
metadata: Dict[str, Any] = {
|
metadata: Dict[str, Any] = {
|
||||||
"trusted": False,
|
"trusted": False,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
|||||||
"""
|
"""
|
||||||
from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg
|
from core.database import SessionLocal, Session as DbSession, ChatMessage as DbMsg
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
from src.task_endpoint import resolve_task_endpoint
|
from src.task_endpoint import resolve_task_endpoint, task_llm_call_async
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -164,8 +164,6 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
|||||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions (folder sort skipped)."
|
return f"Cleaned {deleted_empty + deleted_throwaway} sessions (folder sort skipped)."
|
||||||
|
|
||||||
url, model, headers = resolve_task_endpoint(owner=owner or None)
|
url, model, headers = resolve_task_endpoint(owner=owner or None)
|
||||||
if not url:
|
|
||||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. No model endpoint available for sorting."
|
|
||||||
|
|
||||||
names_text = "\n".join(f' "{s["id"][:8]}": "{s["name"]}"' for s in session_list)
|
names_text = "\n".join(f' "{s["id"][:8]}": "{s["name"]}"' for s in session_list)
|
||||||
prompt = (
|
prompt = (
|
||||||
@@ -183,8 +181,12 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
|||||||
try:
|
try:
|
||||||
# 16384 (was 4096): large folder JSON + reasoning-model thinking
|
# 16384 (was 4096): large folder JSON + reasoning-model thinking
|
||||||
# overflowed 4096 and truncated the JSON, so it never parsed.
|
# overflowed 4096 and truncated the JSON, so it never parsed.
|
||||||
raw = await llm_call_async(url, model, [{"role": "user", "content": prompt}],
|
raw = await task_llm_call_async(
|
||||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120)
|
[{"role": "user", "content": prompt}],
|
||||||
|
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||||
|
owner=owner or None,
|
||||||
|
temperature=0.3, max_tokens=16384, timeout=120,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Auto-sort LLM call failed: {e}")
|
logger.warning(f"Auto-sort LLM call failed: {e}")
|
||||||
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. Folder sort skipped (model unreachable)."
|
return f"Cleaned {deleted_empty + deleted_throwaway} sessions. Folder sort skipped (model unreachable)."
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ DEFAULT_SETTINGS = {
|
|||||||
"image_gen_enabled": False,
|
"image_gen_enabled": False,
|
||||||
"image_model": "",
|
"image_model": "",
|
||||||
"image_quality": "medium",
|
"image_quality": "medium",
|
||||||
|
# Local editor-agent/planner model (Quick Edit NL planner). When empty the
|
||||||
|
# server auto-detects the local Qwen3.5-9B vision server on seneca and falls
|
||||||
|
# back to the hosted chat model. See src/editor_agent_model.py.
|
||||||
|
"editor_agent_base_url": "",
|
||||||
|
"editor_agent_model": "",
|
||||||
|
"editor_caption_base_url": "",
|
||||||
|
"editor_caption_model": "",
|
||||||
"vision_model": "",
|
"vision_model": "",
|
||||||
"vision_enabled": True,
|
"vision_enabled": True,
|
||||||
# Ordered fallback chain for the Vision model (image analysis, OCR, tagging).
|
# Ordered fallback chain for the Vision model (image analysis, OCR, tagging).
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Process-wide shutdown signalling for graceful restarts.
|
||||||
|
|
||||||
|
Server-Sent-Events / streaming responses never complete on their own, so
|
||||||
|
uvicorn's graceful shutdown would wait for an open browser stream up to the
|
||||||
|
full ``timeout_graceful_shutdown`` window before exiting. A SIGTERM/SIGINT
|
||||||
|
handler flips this flag and tells uvicorn to exit; every streaming generator
|
||||||
|
checks the flag on each loop and ends promptly, so a restart completes in
|
||||||
|
seconds while ordinary in-flight requests still finish.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SHUTTING_DOWN = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def is_shutting_down() -> bool:
|
||||||
|
"""True once a shutdown (SIGTERM/SIGINT) has been requested."""
|
||||||
|
return _SHUTTING_DOWN.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
def request_shutdown() -> None:
|
||||||
|
"""Mark the process as shutting down (idempotent)."""
|
||||||
|
if not _SHUTTING_DOWN.is_set():
|
||||||
|
_SHUTTING_DOWN.set()
|
||||||
|
logger.info("shutdown requested; streaming responses will end promptly")
|
||||||
|
|
||||||
|
|
||||||
|
def reset() -> None:
|
||||||
|
"""Clear the flag (for tests)."""
|
||||||
|
_SHUTTING_DOWN.clear()
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""Ajax Spec turn: the Spec router drives every step; the chat model never chooses a tool.
|
||||||
|
|
||||||
|
Each round the router (spec_router.next_action) picks one action from the full tool surface:
|
||||||
|
tool -> the chat model gets only that tool (named tool_choice) and writes its arguments;
|
||||||
|
the executor runs it; the result goes back to the router for the next pick.
|
||||||
|
answer_directly -> the chat model writes the answer with no tools (tools key omitted).
|
||||||
|
ask_user -> the chat model writes the question through the ask_user tool.
|
||||||
|
Router unavailable -> the chat model answers without tools. Execution keeps the normal request
|
||||||
|
authority and tool policy (execute_tool_block); nothing else is layered on top.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src import spec_router
|
||||||
|
from src.agent_runtime.authority import MISSING_AUTHORITY, with_request_authority
|
||||||
|
from src.clean_agent_preview import (
|
||||||
|
event, preview_model_response, preview_tool_result_text,
|
||||||
|
)
|
||||||
|
from src.tool_capabilities import ToolRunSecurityContext
|
||||||
|
from src.tool_execution import execute_tool_block
|
||||||
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS, function_call_to_tool_block
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(content):
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
return '\n'.join(str(p.get('text') or '') for p in content
|
||||||
|
if isinstance(p, dict) and p.get('type') == 'text')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _name(schema):
|
||||||
|
return (schema.get('function') or {}).get('name') or ''
|
||||||
|
|
||||||
|
|
||||||
|
# JSON-schema keywords the provider's guided decoding (named tool_choice) cannot compile.
|
||||||
|
_GRAMMAR_UNSUPPORTED = frozenset({
|
||||||
|
'uniqueItems', 'contains', 'minContains', 'maxContains', 'patternProperties', 'propertyNames',
|
||||||
|
'dependentRequired', 'dependentSchemas', 'if', 'then', 'else',
|
||||||
|
'unevaluatedProperties', 'unevaluatedItems',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def grammar_safe(schema):
|
||||||
|
"""Copy of a schema without keywords guided decoding rejects (validation still runs at execution)."""
|
||||||
|
if isinstance(schema, dict):
|
||||||
|
return {k: grammar_safe(v) for k, v in schema.items() if k not in _GRAMMAR_UNSUPPORTED}
|
||||||
|
if isinstance(schema, list):
|
||||||
|
return [grammar_safe(v) for v in schema]
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def spec_surface(disabled_tools):
|
||||||
|
disabled = set(disabled_tools or ())
|
||||||
|
return [s for s in FUNCTION_TOOL_SCHEMAS if _name(s) and _name(s) not in disabled]
|
||||||
|
|
||||||
|
|
||||||
|
async def _model_round(client, endpoint_url, headers, request, recovery):
|
||||||
|
"""Run one chat-model round; return (content, tool_calls, usage)."""
|
||||||
|
content, calls, usage, raw = '', {}, {}, []
|
||||||
|
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if len(raw) < 4 and line.strip():
|
||||||
|
raw.append(line[:400])
|
||||||
|
if line.strip() and not line.startswith('data: '):
|
||||||
|
logger.warning('[spec-driver] unexpected stream line: %s', line[:500])
|
||||||
|
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chunk = json.loads(line[6:])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if chunk.get('error') or chunk.get('object') == 'error':
|
||||||
|
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
||||||
|
usage = chunk.get('usage') or usage
|
||||||
|
for choice in chunk.get('choices') or []:
|
||||||
|
delta = choice.get('delta') or {}
|
||||||
|
text = delta.get('content') or ''
|
||||||
|
content += text
|
||||||
|
for frag in delta.get('tool_calls') or []:
|
||||||
|
call = calls.setdefault(frag.get('index', 0), {'id': '', 'name': '', 'arguments': ''})
|
||||||
|
call['id'] = frag.get('id') or call['id']
|
||||||
|
fn = frag.get('function') or {}
|
||||||
|
call['name'] += fn.get('name') or ''
|
||||||
|
call['arguments'] += fn.get('arguments') or ''
|
||||||
|
if not calls:
|
||||||
|
logger.warning('[spec-driver] no tool call; status=%s max_tokens=%s msgs=%s first lines=%s',
|
||||||
|
getattr(response, 'status_code', None), request.get('max_tokens'),
|
||||||
|
[(m.get('role'), type(m.get('content')).__name__,
|
||||||
|
[p.get('type') for p in m['content']] if isinstance(m.get('content'), list) else len(str(m.get('content') or '')))
|
||||||
|
for m in request.get('messages', [])], raw[-2:])
|
||||||
|
return content, [calls[i] for i in sorted(calls)], usage
|
||||||
|
|
||||||
|
|
||||||
|
@with_request_authority
|
||||||
|
async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id, owner,
|
||||||
|
disabled_tools, tool_policy, workspace=None, active_document=None,
|
||||||
|
client_runtime_context=None, external_untrusted_context_seen=False,
|
||||||
|
max_tokens=768, max_rounds=8, temperature=0.0,
|
||||||
|
request_authority=MISSING_AUTHORITY, **ignored):
|
||||||
|
from src.turn_contract import _ACTIVE_CONTRACT
|
||||||
|
_ACTIVE_CONTRACT.set(None) # Spec, not the regex turn contract, decides the tools.
|
||||||
|
started = time.monotonic()
|
||||||
|
surface = spec_surface(disabled_tools)
|
||||||
|
by_name = {_name(s): s for s in surface}
|
||||||
|
user_text = next((_text(m.get('content')) for m in reversed(messages) if m.get('role') == 'user'), '')
|
||||||
|
history = list(messages)
|
||||||
|
security = ToolRunSecurityContext(
|
||||||
|
external_untrusted_context_seen=bool(external_untrusted_context_seen), unattended_tools=frozenset())
|
||||||
|
security.observe_messages(messages)
|
||||||
|
picks, executions, done_calls = [], [], set()
|
||||||
|
latest, first_token, usage_in, usage_out, rounds = '', None, 0, 0, 0
|
||||||
|
recovery = {}
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
||||||
|
for round_number in range(1, max_rounds + 1):
|
||||||
|
rounds = round_number
|
||||||
|
action = await asyncio.to_thread(
|
||||||
|
spec_router.next_action, user_text, latest, surface, messages[:-1], disabled_tools)
|
||||||
|
if action is None:
|
||||||
|
action = {'kind': 'answer_directly', 'name': 'router_unavailable', 'p': None}
|
||||||
|
if round_number == max_rounds and action['kind'] == 'tool':
|
||||||
|
action = {'kind': 'answer_directly', 'name': 'round_limit', 'p': None}
|
||||||
|
picks.append({'round': round_number, **action})
|
||||||
|
logger.info('[spec-driver] round=%s pick=%s p=%s', round_number, action['name'], action.get('p'))
|
||||||
|
request = {'model': model, 'messages': history, 'max_tokens': max_tokens,
|
||||||
|
'temperature': temperature, 'stream': True,
|
||||||
|
'stream_options': {'include_usage': True},
|
||||||
|
'chat_template_kwargs': {'enable_thinking': False}}
|
||||||
|
|
||||||
|
if action['kind'] == 'answer_directly':
|
||||||
|
answer_parts = []
|
||||||
|
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chunk = json.loads(line[6:])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if chunk.get('error') or chunk.get('object') == 'error':
|
||||||
|
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
||||||
|
u = chunk.get('usage') or {}
|
||||||
|
usage_in += u.get('prompt_tokens') or 0
|
||||||
|
usage_out += u.get('completion_tokens') or 0
|
||||||
|
for choice in chunk.get('choices') or []:
|
||||||
|
text = (choice.get('delta') or {}).get('content') or ''
|
||||||
|
if text:
|
||||||
|
first_token = first_token or time.monotonic()
|
||||||
|
answer_parts.append(text)
|
||||||
|
yield event({'delta': text})
|
||||||
|
answer = ''.join(answer_parts).strip()
|
||||||
|
if not answer:
|
||||||
|
yield event({'delta': 'The model returned no answer.'})
|
||||||
|
history.append({'role': 'assistant', 'content': answer})
|
||||||
|
break
|
||||||
|
|
||||||
|
name = 'ask_user' if action['kind'] == 'ask_user' else action['name']
|
||||||
|
# ODYSSEUS_SPEC_DRIVER_TOPK=k>1: offer Spec's top-k tools and let the chat model choose among them.
|
||||||
|
topk = int(os.getenv('ODYSSEUS_SPEC_DRIVER_TOPK', '1') or 1)
|
||||||
|
options = [name]
|
||||||
|
if action['kind'] == 'tool' and topk > 1:
|
||||||
|
options = [n for n, _ in action.get('top') or []][:topk] or [name]
|
||||||
|
schemas = [by_name.get(n) or next((s for s in FUNCTION_TOOL_SCHEMAS if _name(s) == n), None)
|
||||||
|
for n in options]
|
||||||
|
options = [n for n, sc in zip(options, schemas) if sc is not None]
|
||||||
|
schemas = [sc for sc in schemas if sc is not None]
|
||||||
|
if not schemas:
|
||||||
|
logger.info('[spec-driver] pick %s has no schema; answering instead', name)
|
||||||
|
latest = f'Tool {name} is not available.'
|
||||||
|
continue
|
||||||
|
request['tools'] = [grammar_safe(sc) for sc in schemas]
|
||||||
|
request['tool_choice'] = ({'type': 'function', 'function': {'name': options[0]}}
|
||||||
|
if len(options) == 1 else 'required')
|
||||||
|
if len(options) > 1:
|
||||||
|
logger.info('[spec-driver] round=%s options=%s', round_number, options)
|
||||||
|
try:
|
||||||
|
content, calls, usage = await _model_round(client, endpoint_url, headers, request, recovery)
|
||||||
|
except (RuntimeError, httpx.HTTPError) as exc:
|
||||||
|
logger.warning('[spec-driver] round=%s %s call failed: %s', round_number, name, exc)
|
||||||
|
latest = f'The {name} call could not be produced: {exc}'
|
||||||
|
continue
|
||||||
|
first_token = first_token or time.monotonic()
|
||||||
|
usage_in += usage.get('prompt_tokens') or 0
|
||||||
|
usage_out += usage.get('completion_tokens') or 0
|
||||||
|
call = next((c for c in calls if c['name'] in options), None)
|
||||||
|
if call is not None and len(options) > 1:
|
||||||
|
name = call['name']
|
||||||
|
logger.info('[spec-driver] round=%s model chose=%s rank=%s of %s',
|
||||||
|
round_number, name, options.index(name) + 1, options)
|
||||||
|
picks[-1]['chosen'] = name
|
||||||
|
if call is None:
|
||||||
|
latest = f'The {name} call was not produced.'
|
||||||
|
logger.info('[spec-driver] round=%s no %s call from the model', round_number, name)
|
||||||
|
continue
|
||||||
|
arguments = call['arguments'] or '{}'
|
||||||
|
signature = (name, arguments)
|
||||||
|
call_id = call['id'] or f'call_{uuid.uuid4().hex[:12]}'
|
||||||
|
history.append({'role': 'assistant', 'content': content or '',
|
||||||
|
'tool_calls': [{'id': call_id, 'type': 'function',
|
||||||
|
'function': {'name': name, 'arguments': arguments}}]})
|
||||||
|
if signature in done_calls:
|
||||||
|
output = 'This exact call already succeeded; its result is above. Do not repeat it.'
|
||||||
|
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
||||||
|
latest = output
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
args = json.loads(arguments)
|
||||||
|
except ValueError:
|
||||||
|
args = {}
|
||||||
|
block = function_call_to_tool_block(name, arguments)
|
||||||
|
yield event({'type': 'tool_start', 'tool': name, 'command': arguments,
|
||||||
|
'full_command': arguments, 'round': round_number})
|
||||||
|
tool_started = time.monotonic()
|
||||||
|
if block is None:
|
||||||
|
desc, result = f'{name}: invalid arguments', {
|
||||||
|
'error': 'Tool arguments could not be converted for execution.', 'exit_code': 1}
|
||||||
|
else:
|
||||||
|
desc, result = await execute_tool_block(
|
||||||
|
block, session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
||||||
|
tool_policy=tool_policy, security_context=security,
|
||||||
|
active_document_id=getattr(active_document, 'id', None), workspace=workspace,
|
||||||
|
client_runtime_context=client_runtime_context, request_authority=request_authority)
|
||||||
|
failed = bool(result.get('error')) or result.get('exit_code') not in (None, 0)
|
||||||
|
output = preview_tool_result_text(result, name, args)
|
||||||
|
tool_event = {'type': 'tool_output', 'tool': name, 'command': arguments, 'output': output,
|
||||||
|
'exit_code': result.get('exit_code', 1 if failed else 0), 'error': failed,
|
||||||
|
'execution_attempted': block is not None, 'blocked': bool(result.get('blocked')),
|
||||||
|
'desc': desc, 'round': round_number,
|
||||||
|
'seconds': round(time.monotonic() - tool_started, 3)}
|
||||||
|
if result.get('image_url'):
|
||||||
|
yield event({'type': 'generated_image', 'url': result['image_url'],
|
||||||
|
**{k: result[k] for k in ('image_url', 'image_id', 'image_prompt') if k in result}})
|
||||||
|
if result.get('ui_event'):
|
||||||
|
yield event({'type': 'ui_control', 'data': result})
|
||||||
|
executions.append(tool_event)
|
||||||
|
yield event(tool_event)
|
||||||
|
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
||||||
|
if not failed:
|
||||||
|
done_calls.add(signature)
|
||||||
|
latest = output
|
||||||
|
ask = result.get('ask_user')
|
||||||
|
if isinstance(ask, dict) and str(ask.get('question') or '').strip():
|
||||||
|
question = str(ask['question']).strip()
|
||||||
|
history.append({'role': 'assistant', 'content': question})
|
||||||
|
yield event({'type': 'final_response', 'content': question})
|
||||||
|
yield event({'type': 'ask_user', 'data': ask})
|
||||||
|
break
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
yield event({'type': 'metrics', 'data': {
|
||||||
|
'model': model, 'input_tokens': usage_in, 'output_tokens': usage_out,
|
||||||
|
'total_tokens': usage_in + usage_out, 'response_time': round(elapsed, 3),
|
||||||
|
'time_to_first_token': round(first_token - started, 3) if first_token else None,
|
||||||
|
'tokens_per_second': round(usage_out / elapsed, 2) if elapsed > 0 else 0,
|
||||||
|
'agent_rounds': rounds, 'tool_calls': len(executions), 'tool_events': executions,
|
||||||
|
'spec_driver_picks': picks, 'tool_schema_count': len(surface),
|
||||||
|
'schema_mode': 'spec_driver', 'temperature': temperature, 'max_output_tokens': max_tokens,
|
||||||
|
}})
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
"""Odysseus spec: small-model tool router (JEFF-style) for the agent harness.
|
||||||
|
|
||||||
|
The router (a 0.8B decision model served by jeff-serve) reads the user's
|
||||||
|
message and scores every offered tool in one forward pass. The harness only
|
||||||
|
ever ADDS tools from its answer, never removes any, and fails open: on any
|
||||||
|
error, timeout or low-confidence answer the compact tool set is unchanged.
|
||||||
|
|
||||||
|
Env:
|
||||||
|
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
|
||||||
|
ODYSSEUS_SPEC_ROUTER_MODEL adapter name (default: odysseus-s60)
|
||||||
|
ODYSSEUS_SPEC_ROUTER_TIMEOUT seconds (default: 0.5)
|
||||||
|
ODYSSEUS_SPEC_ROUTER_MIN_CONF minimum top-1 probability to act (default: 0.3)
|
||||||
|
ODYSSEUS_SPEC_ROUTER_SECOND minimum 2nd-choice probability to also add it (default: 0.25)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_HEAD = {
|
||||||
|
"answer_directly": "No tool is needed: answer the user directly from the conversation",
|
||||||
|
"ask_user": "A tool is needed but required information is missing: ask the user first",
|
||||||
|
}
|
||||||
|
_INSTRUCTIONS = (
|
||||||
|
"Which tool should the agent call next to handle the user's latest message? "
|
||||||
|
"Use the conversation for context. If no tool is needed, choose answer directly. "
|
||||||
|
"If a tool is needed but information it requires is missing, choose ask the user."
|
||||||
|
)
|
||||||
|
_AGENT = "Odysseus, a personal tool-using assistant."
|
||||||
|
# Recent decisions, for debugging in the UI/logs: most recent last.
|
||||||
|
LAST_DECISIONS: List[Dict[str, Any]] = []
|
||||||
|
_MCP_BROWSER_TOOLS = (
|
||||||
|
"mcp__builtin_browser__browser_navigate",
|
||||||
|
"mcp__builtin_browser__browser_snapshot",
|
||||||
|
"mcp__builtin_browser__browser_click",
|
||||||
|
"mcp__builtin_browser__browser_type",
|
||||||
|
"mcp__builtin_browser__browser_press_key",
|
||||||
|
"mcp__builtin_browser__browser_wait_for",
|
||||||
|
"mcp__builtin_browser__browser_take_screenshot",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fn(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return schema.get("function") if isinstance(schema.get("function"), dict) else schema
|
||||||
|
|
||||||
|
|
||||||
|
def tool_line(schema: Dict[str, Any]) -> str:
|
||||||
|
fn = _fn(schema)
|
||||||
|
name = str(fn.get("name") or "")
|
||||||
|
props = ((fn.get("parameters") or {}).get("properties") or {})
|
||||||
|
if os.getenv("ODYSSEUS_SPEC_TOOL_FORMAT", "").strip().lower() == "names":
|
||||||
|
return name + "(" + ", ".join(props) + ")" # names + parameters only (spec_v1n adapters are trained on this)
|
||||||
|
desc = " ".join(str(fn.get("description") or "").split())
|
||||||
|
if len(desc) > 220:
|
||||||
|
desc = desc[:217] + "..."
|
||||||
|
return f"{name}({', '.join(props)}): {desc}"
|
||||||
|
|
||||||
|
|
||||||
|
def prior_tool_names(messages: Optional[Iterable[Dict[str, Any]]]) -> Set[str]:
|
||||||
|
"""Tools already called in this conversation (kept offered: no 'forgetting')."""
|
||||||
|
seen: Set[str] = set()
|
||||||
|
for m in messages or []:
|
||||||
|
if not isinstance(m, dict) or m.get("role") != "assistant":
|
||||||
|
continue
|
||||||
|
for tc in m.get("tool_calls") or []:
|
||||||
|
name = (tc.get("function") or tc).get("name") if isinstance(tc, dict) else None
|
||||||
|
if name:
|
||||||
|
seen.add(str(name))
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _conversation(messages: Optional[Iterable[Dict[str, Any]]], limit: int = 4) -> List[Dict[str, str]]:
|
||||||
|
out: List[Dict[str, str]] = []
|
||||||
|
for m in list(messages or []):
|
||||||
|
if isinstance(m, dict) and m.get("role") in ("user", "assistant") and isinstance(m.get("content"), str) and m["content"].strip():
|
||||||
|
out.append({"role": m["role"], "content": m["content"][:600]})
|
||||||
|
# the last user message is passed separately as user_message
|
||||||
|
if out and out[-1]["role"] == "user":
|
||||||
|
out = out[:-1]
|
||||||
|
return out[-limit:]
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool(os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def spec_route_tools(
|
||||||
|
user_message: str,
|
||||||
|
schemas: Iterable[Dict[str, Any]],
|
||||||
|
disabled_tools: Optional[Set[str]] = None,
|
||||||
|
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||||
|
return_decision: bool = False,
|
||||||
|
):
|
||||||
|
"""Return tool names the router wants offered (plus tools already used).
|
||||||
|
|
||||||
|
With return_decision=True returns (tools, decided) where decided is True when
|
||||||
|
the router answered confidently (a tool pick, or answer_directly)."""
|
||||||
|
added: Set[str] = set(prior_tool_names(messages))
|
||||||
|
decided = False
|
||||||
|
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||||
|
if not url or not (user_message or "").strip():
|
||||||
|
return (added, False) if return_decision else added
|
||||||
|
t0 = time.time()
|
||||||
|
decision: Dict[str, Any] = {"ts": time.time(), "message": user_message[:160], "sticky": sorted(added)}
|
||||||
|
try:
|
||||||
|
disabled = set(disabled_tools or ())
|
||||||
|
lines: Dict[str, str] = {}
|
||||||
|
for s in schemas:
|
||||||
|
n = str(_fn(s).get("name") or "")
|
||||||
|
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||||
|
lines[n] = tool_line(s)
|
||||||
|
names = sorted(lines)
|
||||||
|
criteria = dict(_HEAD)
|
||||||
|
for i, n in enumerate(names, 1):
|
||||||
|
criteria[f"t{i}"] = lines[n]
|
||||||
|
req = {
|
||||||
|
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60"),
|
||||||
|
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": user_message},
|
||||||
|
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||||
|
}
|
||||||
|
http = urllib.request.Request(
|
||||||
|
url + "/v1/systemone", data=json.dumps(req).encode(), headers={"content-type": "application/json"}
|
||||||
|
)
|
||||||
|
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
|
||||||
|
break
|
||||||
|
except urllib.error.HTTPError as busy:
|
||||||
|
# 529 = router busy with another request; retry within budget.
|
||||||
|
if busy.code != 529 or time.time() + 0.15 >= deadline:
|
||||||
|
raise
|
||||||
|
decision["retries"] = decision.get("retries", 0) + 1
|
||||||
|
time.sleep(0.1)
|
||||||
|
probs = out.get("probabilities") or {}
|
||||||
|
choice = out.get("choice")
|
||||||
|
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
||||||
|
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
||||||
|
ranked = sorted(
|
||||||
|
((p, k) for k, p in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True
|
||||||
|
)
|
||||||
|
picks: List[str] = []
|
||||||
|
if choice and choice.startswith("t") and choice[1:].isdigit() and float(probs.get(choice, 0.0)) >= min_conf:
|
||||||
|
picks.append(names[int(choice[1:]) - 1])
|
||||||
|
for p, k in ranked[:3]:
|
||||||
|
nm = names[int(k[1:]) - 1]
|
||||||
|
if nm not in picks and p >= second_min:
|
||||||
|
picks.append(nm)
|
||||||
|
_bmode = os.getenv("ODYSSEUS_SPEC_BROWSER_FALLBACK", "0")
|
||||||
|
if _bmode != "0" and "private_browser" in picks:
|
||||||
|
# This build disables page execution in private_browser (see
|
||||||
|
# src/browser_identity.py). "mcp": use the connected Playwright MCP
|
||||||
|
# browser (no run_code/evaluate); "web": fetch/search only.
|
||||||
|
picks = [n for n in picks if n != "private_browser"]
|
||||||
|
extra = list(_MCP_BROWSER_TOOLS) if _bmode == "mcp" else []
|
||||||
|
for n in extra + ["web_search", "web_fetch"]:
|
||||||
|
if n not in picks:
|
||||||
|
picks.append(n)
|
||||||
|
added.update(picks)
|
||||||
|
decided = bool(picks) or (choice == "answer_directly" and float(probs.get("answer_directly", 0.0)) >= min_conf)
|
||||||
|
decision.update(choice=choice, tools=picks, ms=round((time.time() - t0) * 1000, 1),
|
||||||
|
top=[(names[int(k[1:]) - 1], round(p, 3)) for p, k in ranked[:3]],
|
||||||
|
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
||||||
|
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
||||||
|
except Exception as exc: # fail open
|
||||||
|
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
||||||
|
logger.warning("spec router failed open: %s", exc)
|
||||||
|
LAST_DECISIONS.append(decision)
|
||||||
|
del LAST_DECISIONS[:-50]
|
||||||
|
decision["decided"] = decided
|
||||||
|
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||||
|
return (added, decided) if return_decision else added
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Ajax Spec: system prompt trimmed to the tools actually offered ----------
|
||||||
|
_CORE_PROMPT = (
|
||||||
|
"You are Ajax, the assistant in Odysseus. Emit one native tool call, then stop. "
|
||||||
|
"After a successful tool result, answer from it; never repeat the same tool with the same arguments."
|
||||||
|
)
|
||||||
|
_CODING_PROMPT = (
|
||||||
|
"HARD CODING ORDER: for code changes call read_file, then edit_file or apply_patch, then host_shell for tests. "
|
||||||
|
"Never run tests before the edit succeeds; after a read, your next call is the edit. "
|
||||||
|
"edit_file JSON is {path,old_string,new_string}; write_file only for new files or full rewrites."
|
||||||
|
)
|
||||||
|
_TOOL_NOTES = {
|
||||||
|
"manage_notes": "manage_notes: notes/checklists.",
|
||||||
|
"manage_documents": "manage_documents: document library; actions list/read/delete/tidy, use list+search to find by title/content.",
|
||||||
|
"create_document": "create_document: create editor-panel documents with title/content.",
|
||||||
|
"edit_document": "edit_document: edit the active editor document using command=<<<FIND>>>old<<<REPLACE>>>new<<<END>>> or edits=[{find,replace}].",
|
||||||
|
"update_document": "update_document: full active-document rewrite only.",
|
||||||
|
"manage_calendar": "manage_calendar: calendar events; actions create_event, update_event, delete_event, list_events.",
|
||||||
|
"manage_tasks": "manage_tasks: scheduled/recurring tasks; create uses action=create, name, prompt, task_type, schedule, scheduled_time, output_target; pause/resume/delete use task_id, so list/search first when only the name is known. Recurring reminders use manage_tasks.",
|
||||||
|
"manage_memory": "manage_memory: saved memories.",
|
||||||
|
"manage_contact": "manage_contact: address-book contacts.",
|
||||||
|
"manage_research": "manage_research: saved research reports.",
|
||||||
|
"manage_settings": "manage_settings: app settings.",
|
||||||
|
"manage_endpoints": "manage_endpoints: configured API endpoints.",
|
||||||
|
"list_models": "list_models: available models.",
|
||||||
|
"manage_mcp": "manage_mcp: MCP servers.",
|
||||||
|
"manage_skills": "manage_skills: skills.",
|
||||||
|
"manage_webhooks": "manage_webhooks: webhooks.",
|
||||||
|
"manage_bg_jobs": "manage_bg_jobs: running/background jobs.",
|
||||||
|
"search_chats": "search_chats: past chats.",
|
||||||
|
"web_search": "web_search: public web lookup, URLs, links.",
|
||||||
|
"web_fetch": "web_fetch: fetch a concrete URL/domain.",
|
||||||
|
"private_browser": "private_browser: rendered site interaction. Make separate calls: first action=open with the url, then action=snapshot; read/scroll/find as needed. Do not use action=batch. Only click/fill element refs returned by snapshot (for example @e12), never guessed labels.",
|
||||||
|
"mcp__builtin_browser__browser_navigate": "Browser: browser_navigate {url} opens a page; then browser_snapshot returns the page with element refs; browser_click/browser_type take ref values from the latest snapshot, never guessed labels.",
|
||||||
|
"youtube_tool": "youtube_tool: YouTube comments, transcripts, metadata, and latest channel video.",
|
||||||
|
"read_file": "read_file: workspace text, PDF, or Office document path.",
|
||||||
|
"edit_file": "edit_file: exact replacement in an existing workspace file.",
|
||||||
|
"write_file": "write_file: create or fully rewrite a workspace file.",
|
||||||
|
"apply_patch": "apply_patch: related multi-file workspace edits.",
|
||||||
|
"host_shell": "host_shell: run tests, builds, and verification in the active workspace.",
|
||||||
|
}
|
||||||
|
_EMAIL_NOTES = (
|
||||||
|
"Email: latest email/emails/inbox -> mcp__email__list_emails {folder:INBOX,max_results:1,unread_only:false}; "
|
||||||
|
"account list -> list_email_accounts; subject find+read -> mcp__email__list_emails {folder:INBOX,max_results:20}; "
|
||||||
|
"sender/topic search -> mcp__email__search_emails {max_results:10}; known id -> mcp__email__read_email; "
|
||||||
|
"new draft -> mcp__email__draft_email; attachment -> mcp__email__download_attachment {uid,index,folder,account}; "
|
||||||
|
"reply draft -> mcp__email__draft_email_reply {uid,account,body}; only explicit send-now -> mcp__email__reply_to_email {uid,account,body}; "
|
||||||
|
"archive/delete/mark -> mcp__email__archive_email/mcp__email__delete_email/mcp__email__mark_email_read. Never emit manage_email."
|
||||||
|
)
|
||||||
|
_AJAX_PROMPT_START = "You are Ajax, the assistant in Odysseus."
|
||||||
|
|
||||||
|
|
||||||
|
def trimmed_system_prompt(offered: Iterable[str]) -> str:
|
||||||
|
offered = set(offered or ())
|
||||||
|
parts = [_CORE_PROMPT]
|
||||||
|
if offered & {"edit_file", "apply_patch", "host_shell"}:
|
||||||
|
parts.append(_CODING_PROMPT)
|
||||||
|
parts += [_TOOL_NOTES[n] for n in sorted(offered) if n in _TOOL_NOTES]
|
||||||
|
if any(n.startswith("mcp__email__") or n in {"list_email_accounts"} for n in offered):
|
||||||
|
parts.append(_EMAIL_NOTES)
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def trim_system_prompt(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""Swap the generic Ajax tool manual for one covering only the offered tools."""
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for m in messages:
|
||||||
|
if (
|
||||||
|
isinstance(m, dict)
|
||||||
|
and m.get("role") == "system"
|
||||||
|
and isinstance(m.get("content"), str)
|
||||||
|
and m["content"].startswith(_AJAX_PROMPT_START)
|
||||||
|
):
|
||||||
|
new = dict(m)
|
||||||
|
new["content"] = trimmed_system_prompt(offered)
|
||||||
|
logger.info("[spec-harness] system prompt trimmed %d -> %d chars", len(m["content"]), len(new["content"]))
|
||||||
|
out.append(new)
|
||||||
|
else:
|
||||||
|
out.append(m)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def drop_skills_index(messages: List[Dict[str, Any]], offered: Iterable[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""Ajax Spec: omit the ~1000-token skills index unless the skills tool is offered."""
|
||||||
|
if "manage_skills" in set(offered or ()):
|
||||||
|
return messages
|
||||||
|
out = [
|
||||||
|
m for m in messages
|
||||||
|
if not (isinstance(m, dict) and (m.get("metadata") or {}).get("source") == "available skills index")
|
||||||
|
]
|
||||||
|
if len(out) != len(messages):
|
||||||
|
logger.info("[spec-harness] dropped skills index (%d chars)", sum(len(str(m.get("content") or "")) for m in messages if m not in out))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- Router-decided thinking (progressive thinking trigger) -------------------------------------------------
|
||||||
|
# The router classifies the user's request as THINK (needs reasoning: math/logic, tricky planning, subtle explanation or
|
||||||
|
# weighing options, first-instinct-likely-wrong questions) or DIRECT (plain doing, lookup, chit-chat).
|
||||||
|
# Zero-shot on 143 DeepSeek-labelled app requests (2026-10-13): odysseus-agentic-v1f acc 0.916, think recall 0.985,
|
||||||
|
# think precision 0.859, ~33 ms. Off unless ODYSSEUS_SPEC_THINK_ROUTER=1. Never raises; returns None on any error.
|
||||||
|
_THINK_DEF = ("This request needs genuine reasoning or figuring something out before answering: math and logic puzzles; "
|
||||||
|
"planning a tricky multi-step approach; explaining something subtle or weighing options; or questions where "
|
||||||
|
"a quick first instinct is likely wrong.")
|
||||||
|
_DIRECT_DEF = ("This request is plain doing, lookup, or chit-chat that can be answered directly without reasoning: simple "
|
||||||
|
"tool actions (set a reminder, search email, open a page, rename a file), simple facts, or small talk.")
|
||||||
|
_THINK_INSTRUCTIONS = ("Classify whether this single user request needs the assistant to THINK (deep reasoning / figuring "
|
||||||
|
"something out) before answering, or can be answered DIRECTLY (plain doing, lookup, or chit-chat). "
|
||||||
|
"Answer with exactly one of the two options.")
|
||||||
|
|
||||||
|
|
||||||
|
# 5-way request kind, exactly as trained into adapter thinkclass5 (2026-10-13; held-out 143: think/direct 96.5%,
|
||||||
|
# think precision 98.8%, recall 95.2%). Enabled with ODYSSEUS_SPEC_THINK_QUESTION=kind5 + ODYSSEUS_SPEC_THINK_MODEL=thinkclass5.
|
||||||
|
_KIND5_QUESTION = {"type": "choice", "instructions": "Read the user's request and classify it into exactly one of five kinds: math_logic, tricky_planning, explain_or_weigh, trap_question, or direct. Pick the option whose description fits best.", "criteria": {"math_logic": "Mathematical or logical computation/reasoning: arithmetic, word problems, logic puzzles, counting, proofs.", "tricky_planning": "Planning a multi-step approach with real constraints: schedules, itineraries, sequencing, resource or budget allocation, multi-part procedures.", "explain_or_weigh": "Explaining something subtle or weighing options/tradeoffs: comparisons, 'should I', pros and cons, recommendations with reasoning, nuanced why-explanations.", "trap_question": "A question where the obvious or first-instinct answer is likely wrong: riddles, trick questions, common misconceptions, counterintuitive facts.", "direct": "Plain doing, lookup, or chit-chat: simple tool actions, simple facts, small talk — no deep reasoning needed."}}
|
||||||
|
|
||||||
|
|
||||||
|
def think_router_enabled() -> bool:
|
||||||
|
return os.getenv("ODYSSEUS_SPEC_THINK_ROUTER", "0").strip() not in ("", "0", "false", "off")
|
||||||
|
|
||||||
|
|
||||||
|
def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return {'think': bool, 'p_think': float, 'ms': float} or None (disabled / error / empty message)."""
|
||||||
|
if not think_router_enabled():
|
||||||
|
return None
|
||||||
|
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||||
|
text = (user_message or "").strip()
|
||||||
|
if not url or not text:
|
||||||
|
return None
|
||||||
|
kind5 = os.getenv("ODYSSEUS_SPEC_THINK_QUESTION", "binary").strip().lower() == "kind5"
|
||||||
|
question = _KIND5_QUESTION if kind5 else {"type": "choice", "instructions": _THINK_INSTRUCTIONS,
|
||||||
|
"criteria": {"think": _THINK_DEF, "direct": _DIRECT_DEF}}
|
||||||
|
req = {
|
||||||
|
"model": os.getenv("ODYSSEUS_SPEC_THINK_MODEL", os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60")),
|
||||||
|
"state": {"agent": _AGENT, "conversation": [], "user_message": text[:4000]},
|
||||||
|
"questions": {"thinking": question},
|
||||||
|
}
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||||
|
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
|
||||||
|
headers={"content-type": "application/json"})
|
||||||
|
ans = json.load(urllib.request.urlopen(http, timeout=timeout))["answers"]["thinking"]
|
||||||
|
probs = ans.get("probabilities") or {}
|
||||||
|
# kind5: think = any of the four reasoning kinds (trained adapter thinkclass5); binary: P(think).
|
||||||
|
p = float(1.0 - probs.get("direct", 1.0)) if kind5 else float(probs.get("think", 0.0))
|
||||||
|
min_p = float(os.getenv("ODYSSEUS_SPEC_THINK_MIN_P", "0.5"))
|
||||||
|
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
||||||
|
if kind5:
|
||||||
|
out["kind"] = ans.get("choice")
|
||||||
|
logger.info("spec think router: %s msg=%r", out, text[:120])
|
||||||
|
return out
|
||||||
|
except Exception as exc: # fail open: the existing progressive-thinking rules still apply
|
||||||
|
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Ajax Spec driver: the router picks every step --------------------------------------------------------
|
||||||
|
def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
||||||
|
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||||
|
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Top-1 next action at any confidence: {'kind': 'tool'|'answer_directly'|'ask_user', 'name', 'p'}.
|
||||||
|
|
||||||
|
Same request as training (build_agentic_rows.py): sorted tool lines, and mid-turn the latest tool
|
||||||
|
result appended to the user message as "\\n\\nLatest tool result:\\n...". Returns None on any error."""
|
||||||
|
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
t0 = time.time()
|
||||||
|
disabled = set(disabled_tools or ())
|
||||||
|
lines: Dict[str, str] = {}
|
||||||
|
for s in schemas:
|
||||||
|
n = str(_fn(s).get("name") or "")
|
||||||
|
if n and n not in disabled and n != "ask_user" and n not in lines:
|
||||||
|
lines[n] = tool_line(s)
|
||||||
|
names = sorted(lines)
|
||||||
|
criteria = dict(_HEAD)
|
||||||
|
for i, n in enumerate(names, 1):
|
||||||
|
criteria[f"t{i}"] = lines[n]
|
||||||
|
um = (user_message or "")[-3000:]
|
||||||
|
if latest_result:
|
||||||
|
um += "\n\nLatest tool result:\n" + latest_result[-1500:]
|
||||||
|
req = {
|
||||||
|
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-agentic-v4"),
|
||||||
|
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": um},
|
||||||
|
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
http = urllib.request.Request(url + "/v1/systemone", data=json.dumps(req).encode(),
|
||||||
|
headers={"content-type": "application/json"})
|
||||||
|
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
out = json.load(urllib.request.urlopen(http, timeout=max(0.2, deadline - time.time())))["answers"]["tool"]
|
||||||
|
break
|
||||||
|
except urllib.error.HTTPError as busy:
|
||||||
|
if busy.code != 529 or time.time() + 0.15 >= deadline:
|
||||||
|
raise
|
||||||
|
time.sleep(0.1)
|
||||||
|
probs = out.get("probabilities") or {}
|
||||||
|
choice = str(out.get("choice") or "")
|
||||||
|
if choice.startswith("t") and choice[1:].isdigit():
|
||||||
|
action = {"kind": "tool", "name": names[int(choice[1:]) - 1]}
|
||||||
|
elif choice in ("answer_directly", "ask_user"):
|
||||||
|
action = {"kind": choice, "name": choice}
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
||||||
|
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
||||||
|
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
||||||
|
logger.info("spec next action: %s msg=%r", action, (user_message or "")[:80])
|
||||||
|
return action
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("spec next action failed: %s", exc)
|
||||||
|
return None
|
||||||
@@ -216,6 +216,20 @@ _register(
|
|||||||
ToolEffect.WRITE_PRIVATE,
|
ToolEffect.WRITE_PRIVATE,
|
||||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
)
|
)
|
||||||
|
_register(
|
||||||
|
{"ai_edit_image"},
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"get_editor_screenshot"},
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"plan_edit", "run_editor_command"},
|
||||||
|
ToolEffect.UI_SIDE_EFFECT,
|
||||||
|
)
|
||||||
_register(
|
_register(
|
||||||
{
|
{
|
||||||
"archive_email",
|
"archive_email",
|
||||||
|
|||||||
+35
-2
@@ -1558,6 +1558,31 @@ def _agent_subprocess_env() -> dict:
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_private_browser_enabled() -> bool:
|
||||||
|
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _legacy_private_browser(operation, transport, *, progress_cb=None, session_id=None, owner=None):
|
||||||
|
from src.agent_tools.web_tools_prewave3 import PrivateBrowserTool
|
||||||
|
ctx = {
|
||||||
|
"progress_cb": progress_cb,
|
||||||
|
"subproc_env": _agent_subprocess_env(),
|
||||||
|
"session_id": session_id,
|
||||||
|
"owner": owner,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = await PrivateBrowserTool().execute(operation.input, ctx)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning("legacy private_browser failed: %s", error)
|
||||||
|
return f"{transport}: ERROR", {"error": f"private_browser: {error}", "exit_code": 1}
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
result = {"output": str(result), "exit_code": 0}
|
||||||
|
label = "ERROR" if result.get("error") or result.get("exit_code") not in (None, 0) else "EXECUTED"
|
||||||
|
return f"{transport}: {label}", result
|
||||||
|
|
||||||
|
|
||||||
async def _direct_fallback(
|
async def _direct_fallback(
|
||||||
tool: str,
|
tool: str,
|
||||||
content: str,
|
content: str,
|
||||||
@@ -1731,6 +1756,12 @@ async def execute_tool_block(
|
|||||||
}
|
}
|
||||||
|
|
||||||
transport = operation.transport_tool
|
transport = operation.transport_tool
|
||||||
|
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
|
||||||
|
# Restored pre-Wave-3 private_browser (agent-browser CLI). The Wave 3
|
||||||
|
# producer in src.browser_identity refuses every page action in this
|
||||||
|
# build. This compatibility transport requires an explicit opt-in.
|
||||||
|
return await _legacy_private_browser(
|
||||||
|
operation, transport, progress_cb=progress_cb, session_id=session_id, owner=owner)
|
||||||
if operation.tool == "private_browser":
|
if operation.tool == "private_browser":
|
||||||
try:
|
try:
|
||||||
_, browser_args = parse_browser_operation(operation.input)
|
_, browser_args = parse_browser_operation(operation.input)
|
||||||
@@ -2193,7 +2224,8 @@ async def _execute_tool_block_impl(
|
|||||||
if tool == "generate_image":
|
if tool == "generate_image":
|
||||||
from src.ai_interaction import do_generate_image
|
from src.ai_interaction import do_generate_image
|
||||||
desc = "generate_image"
|
desc = "generate_image"
|
||||||
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner))
|
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
|
||||||
|
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner, **({'incognito': True} if image_privacy else {})))
|
||||||
elif (tool in NATIVE_FILESYSTEM_TOOLS
|
elif (tool in NATIVE_FILESYSTEM_TOOLS
|
||||||
and (active_resource_operation() is not None or tool != "apply_patch"
|
and (active_resource_operation() is not None or tool != "apply_patch"
|
||||||
or not _tui_host_bridge_patch_url(client_runtime_context))):
|
or not _tui_host_bridge_patch_url(client_runtime_context))):
|
||||||
@@ -2353,7 +2385,8 @@ async def _execute_tool_block_impl(
|
|||||||
result = await dispatched(do_list_cookbook_servers(content, owner=owner))
|
result = await dispatched(do_list_cookbook_servers(content, owner=owner))
|
||||||
elif tool == "edit_image":
|
elif tool == "edit_image":
|
||||||
desc = "edit_image"
|
desc = "edit_image"
|
||||||
result = await dispatched(do_edit_image(content, owner=owner))
|
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
|
||||||
|
result = await dispatched(do_edit_image(content, owner=owner, **({'incognito': True} if image_privacy else {})))
|
||||||
elif tool == "edit_file":
|
elif tool == "edit_file":
|
||||||
result = await dispatched(_direct_fallback(tool, content)) or {"error": "edit failed", "exit_code": 1}
|
result = await dispatched(_direct_fallback(tool, content)) or {"error": "edit failed", "exit_code": 1}
|
||||||
desc = result.get("output") or result.get("error") or "edit_file"
|
desc = result.get("output") or result.get("error") or "edit_file"
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
|||||||
"list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.",
|
"list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.",
|
||||||
"app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — allowed UI buttons hit /api/* endpoints and you can hit them too. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked; do NOT use app_api for shell commands, package installs, engine rebuilds, or PID signalling. Use named command tooling for shell commands. action='endpoints' with filter=<keyword> lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.",
|
"app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — allowed UI buttons hit /api/* endpoints and you can hit them too. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked; do NOT use app_api for shell commands, package installs, engine rebuilds, or PID signalling. Use named command tooling for shell commands. action='endpoints' with filter=<keyword> lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.",
|
||||||
"edit_image": "Edit an image in the gallery: upscale (increase resolution), remove background (rembg), inpaint (fill selected area), or harmonize (blend edits). Specify image ID and action.",
|
"edit_image": "Edit an image in the gallery: upscale (increase resolution), remove background (rembg), inpaint (fill selected area), or harmonize (blend edits). Specify image ID and action.",
|
||||||
|
"plan_edit": "Plan a multi-step edit for the open image editor and return ordered editor ops to run.",
|
||||||
|
"run_editor_command": "Run one deterministic editor op (adjustments, masked filters, selection, layers, import/place, export, screenshot) on the open image editor.",
|
||||||
|
"get_editor_screenshot": "Capture the open image editor's current canvas as a PNG data URL for inspection.",
|
||||||
|
"ai_edit_image": "Apply an AI image edit (remove/inpaint objects, generative replace, remove-bg, upscale, denoise, enhance-face) to the open image and place the result as a new layer. Use for object removal/addition/replacement; never approximate those with selection/adjustment/filter ops.",
|
||||||
"trigger_research": "Start a deep research job on any topic — appears in the Deep Research sidebar, streams progress, produces a detailed report. Use for 'research X', 'look into Y', 'do deep research on Z', 'investigate'. NOT a scheduled task — it runs now and surfaces in the sidebar.",
|
"trigger_research": "Start a deep research job on any topic — appears in the Deep Research sidebar, streams progress, produces a detailed report. Use for 'research X', 'look into Y', 'do deep research on Z', 'investigate'. NOT a scheduled task — it runs now and surfaces in the sidebar.",
|
||||||
"manage_bg_jobs": "Inspect and control detached background `bash` jobs (the ones started with a `#!bg` marker). action='list' shows this chat's jobs (id/status/age/command); action='output' returns a job's captured output so far (check on a long-running job, or re-read a finished one); action='kill' stops a runaway job by id. Use for 'is the background job done', 'check on that job', 'show the build output', 'kill the background job', 'stop the bg task'. output/kill need a job_id from list.",
|
"manage_bg_jobs": "Inspect and control detached background `bash` jobs (the ones started with a `#!bg` marker). action='list' shows this chat's jobs (id/status/age/command); action='output' returns a job's captured output so far (check on a long-running job, or re-read a finished one); action='kill' stops a runaway job by id. Use for 'is the background job done', 'check on that job', 'show the build output', 'kill the background job', 'stop the bg task'. output/kill need a job_id from list.",
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-2
@@ -8,6 +8,7 @@ Extracted from agent_tools.py to keep schema definitions separate from
|
|||||||
tool parsing / execution logic.
|
tool parsing / execution logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@@ -113,6 +114,15 @@ def _repair_function_arg_aliases(tool_type: str, args: dict[str, Any]) -> dict[s
|
|||||||
args["url"] = path
|
args["url"] = path
|
||||||
args.pop("path", None)
|
args.pop("path", None)
|
||||||
args.pop("file_path", None)
|
args.pop("file_path", None)
|
||||||
|
elif (
|
||||||
|
tool_type == "ui_control"
|
||||||
|
and str(args.get("action") or "").casefold() == "open_panel"
|
||||||
|
and not str(args.get("name") or "").strip()
|
||||||
|
):
|
||||||
|
panel = _first_nonempty_string(args, ("panel",))
|
||||||
|
if panel:
|
||||||
|
args["name"] = panel
|
||||||
|
args.pop("panel", None)
|
||||||
if tool_type == "manage_documents" and "limit" not in args and "max_results" in args:
|
if tool_type == "manage_documents" and "limit" not in args and "max_results" in args:
|
||||||
# Collection APIs use both names across the native tool surface. The
|
# Collection APIs use both names across the native tool surface. The
|
||||||
# document contract calls this integer ``limit``.
|
# document contract calls this integer ``limit``.
|
||||||
@@ -904,13 +914,13 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "ui_control",
|
"name": "ui_control",
|
||||||
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, calendar/schedule, email, sessions, notes, memories/brain, skills, settings, theme, cookbook; calendar supports month/week/year/agenda plus a date; Cookbook supports models/download, launch/serve, active/running, dependencies, and settings views), open_email_reply (legacy UI-only reply opener; prefer email MCP draft_email_reply for assistant-written reply drafts so a normal document-backed email draft is created), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, cyberpunk, retrowave, forest, ocean, ume, terminal, organs, gpt, claude, cute, eclipse, porcelain, arcade, blueprint, monolith, yoyo), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation), get_theme, and get_toggles. When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
|
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, calendar/schedule, email, sessions, notes, tasks, memories/brain, skills, settings, theme, cookbook; calendar supports month/week/year/agenda plus a date; Cookbook supports models/download, launch/serve, active/running, dependencies, and settings views), open_email_reply (legacy UI-only reply opener; prefer email MCP draft_email_reply for assistant-written reply drafts so a normal document-backed email draft is created), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, cyberpunk, retrowave, forest, ocean, ume, terminal, organs, gpt, claude, cute, eclipse, porcelain, arcade, blueprint, monolith, yoyo), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation), get_theme, and get_toggles. When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": {"type": "string", "enum": ["toggle", "open_panel", "open_email_reply", "set_mode", "switch_model", "set_theme", "create_theme", "get_theme", "get_toggles"],
|
"action": {"type": "string", "enum": ["toggle", "open_panel", "open_email_reply", "set_mode", "switch_model", "set_theme", "create_theme", "get_theme", "get_toggles"],
|
||||||
"description": "The UI action. Use set_theme for presets, create_theme to build a custom theme with any hex colors"},
|
"description": "The UI action. Use set_theme for presets, create_theme to build a custom theme with any hex colors"},
|
||||||
"name": {"type": "string", "description": "For toggle: web, bash, research, incognito, document_editor (aliases: shell, search, deepresearch, documents). For open_panel: documents, gallery, calendar/schedule, email, sessions, notes, brain/memories, skills, settings, theme/themes, cookbook; models and serve are Cookbook-view aliases. For open_email_reply: email UID. For set_theme: a preset theme name. For create_theme: the custom theme name."},
|
"name": {"type": "string", "description": "For toggle: web, bash, research, incognito, document_editor (aliases: shell, search, deepresearch, documents). For open_panel: documents, gallery, calendar/schedule, email, sessions, notes, tasks, brain/memories, skills, settings, theme/themes, cookbook; models and serve are Cookbook-view aliases. For open_email_reply: email UID. For set_theme: a preset theme name. For create_theme: the custom theme name."},
|
||||||
"view": {"type": "string", "description": "Optional open_panel subview: calendar day/week/month/year/agenda, or Cookbook models/download, launch/serve, active/running, dependencies, settings."},
|
"view": {"type": "string", "description": "Optional open_panel subview: calendar day/week/month/year/agenda, or Cookbook models/download, launch/serve, active/running, dependencies, settings."},
|
||||||
"value": {"type": "string", "description": "Value: on/off for toggle, agent/chat for set_mode, model name for switch_model, theme name for set_theme, or folder for open_email_reply"},
|
"value": {"type": "string", "description": "Value: on/off for toggle, agent/chat for set_mode, model name for switch_model, theme name for set_theme, or folder for open_email_reply"},
|
||||||
"uid": {"type": "string", "description": "Email UID for open_email_reply"},
|
"uid": {"type": "string", "description": "Email UID for open_email_reply"},
|
||||||
@@ -1510,6 +1520,75 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "plan_edit",
|
||||||
|
"description": "Plan a photo-editing program for the currently open image editor. Returns an ordered list of editor ops to run against the canvas. Route object removal, added content, or replaced content to ai_edit_image/generate_image; use deterministic ops only for tone/colour/filter/crop/text/selection edits. If no image provider is configured for a generative request, say so instead of planning a substitute deterministic edit. Use the canonical adjustment/filter shapes (adjustments.apply/applyMasked params {\"type\", \"params\"}; filters.apply/applyMasked params {\"id\"}); never invent adjustment types or filter ids. New ops: photo.autoTone; retouch.skin/blemish/teeth; selection.face/background. Portrait/look-better ('fix the skin', 'fix my teeth') -> selection.face then retouch.skin (and retouch.teeth for teeth) then ONE gentle photo.autoTone; never a timid single step. Low-key/'fix the lighting' on a dark image: at most ONE global lift (prefer photo.autoTone once or shadows-highlights); never stack autoTone/brightness/exposure/levels, and keep highlights <= 0. Temperature sign: moody/cool/cinematic -> white-balance temperature NEGATIVE; warm/golden/sunset -> POSITIVE. Background softer -> selection.background then filters.applyMasked {id:'blur-gaussian'} (never a plain global blur). A vague prompt with no concrete defect ('clean this up') -> return {\"reply\": <question>, \"steps\": [], \"ask_user\": {\"question\":..., \"options\":[...]}} with 2-3 concrete options instead of guessing.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prompt": {"type": "string", "description": "What the user wants done to the image"},
|
||||||
|
"state": {"type": "object", "description": "Optional current editor state (layers, selection, document info)"}
|
||||||
|
},
|
||||||
|
"required": ["prompt"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "run_editor_command",
|
||||||
|
"description": "Run a single deterministic operation on the open image editor and return its result. Use catalog ops such as adjustments.*, filters.applyMasked, selection.*, layers.*, document.importImage, layers.placeImage, export.png, screenshot. Do NOT use for AI/masked edits (use ai_edit_image) or new imagery (use generate_image). Canonical shapes: adjustments.apply/applyMasked params = {\"type\": <brightness-contrast|exposure|white-balance|hue-saturation|vibrance|black-white|shadows-highlights|levels|curves|color-balance|photo-filter|selective-color|gradient-map|invert|posterize|threshold|channel-mixer|color-lookup>, \"params\": {...}}; filters.apply/applyMasked params = {\"id\": <blur-gaussian|blur-motion|blur-zoom|sharpen|sharpen-edges|unsharp-mask|noise-add|noise-despeckle|pixelate-mosaic|pixelate-crystallize|stylize-emboss|stylize-find-edges|stylize-solarize|distort-twirl|distort-pinch|distort-spherize|distort-ripple|render-clouds>}. Never nest a numeric bag under {\"adjustments\": {...}} and never invent adjustment types or filter ids (no \"vignette\" or \"clarity\"). Additional canonical ops: photo.autoTone {}; retouch.skin {smooth,opacity,blemish,radius,threshold}; retouch.blemish {radius,threshold}; retouch.teeth {desat,bright}; selection.face {mode}; selection.background {mode,subject,fuzziness}. At most one global brightness/exposure lift (prefer shadows-highlights; keep highlights <= 0). Never stack global lifts.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
|
||||||
|
"params": {"type": "object", "description": "Parameters for the op"},
|
||||||
|
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"},
|
||||||
|
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
|
||||||
|
},
|
||||||
|
"required": ["op"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_editor_screenshot",
|
||||||
|
"description": "Capture the open image editor's current canvas as a PNG data URL. Use to inspect the result before choosing the next edit.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "ai_edit_image",
|
||||||
|
"description": "Apply an AI image edit and place the result as a NEW layer, never overwriting the original. Two modes. mode='instruction' (default): send the WHOLE image with a clear prompt and no mask — use a rephrased instruction that NAMES what must stay unchanged, e.g. 'replace the background with a sunny beach, keep the woman exactly the same'. Use this for background/scene replacement, changing the season or style, adding an object to the scene (a moon, a person), and removing diffuse distractions. mode='masked': masked inpaint on a selected region — use ONLY for an explicit regional removal of a clearly identified object ('remove the person on the left'); select the object first so the mask is tight. NEVER approximate object removal/addition/replacement with selection, adjustment, filter or transform ops; if no image provider is configured, report that instead of substituting a deterministic edit. Other ops: remove-bg, upscale (2x/4x), denoise, enhance-face.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"op": {"type": "string", "enum": ["remove-bg", "upscale", "denoise", "enhance-face", "inpaint", "generative"], "description": "AI edit operation"},
|
||||||
|
"mode": {"type": "string", "enum": ["instruction", "masked"], "description": "instruction = whole-image edit with no mask (background/season/style/add-object/diffuse removal); masked = tight-mask regional removal of a selected object. Default instruction."},
|
||||||
|
"prompt": {"type": "string", "description": "Instruction for inpaint/generative (in instruction mode, name what must stay unchanged)"},
|
||||||
|
"image": {"type": "string", "description": "Optional source (data URL or gallery image id); defaults to the current canvas/screenshot"},
|
||||||
|
"mask": {"type": "string", "description": "Optional base64 PNG mask (white = region to edit); required for mode='masked'"},
|
||||||
|
"scale": {"type": "number", "description": "Upscale factor (2 or 4)"},
|
||||||
|
"strength": {"type": "number", "description": "Denoise strength (0-1)"},
|
||||||
|
"steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."},
|
||||||
|
"place": {"type": "boolean", "description": "Place the result as a new layer (default true)"},
|
||||||
|
"model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"},
|
||||||
|
"session": {"type": "string", "description": "Editor session id"}
|
||||||
|
},
|
||||||
|
"required": ["op"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -1921,6 +2000,15 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}:
|
||||||
|
# Restored pre-Wave-3 private_browser (see src.tool_execution._legacy_private_browser).
|
||||||
|
from src.private_browser_legacy_schema import PRIVATE_BROWSER_SCHEMA as _PB_LEGACY
|
||||||
|
|
||||||
|
FUNCTION_TOOL_SCHEMAS[:] = [
|
||||||
|
_PB_LEGACY if (schema.get("function") or {}).get("name") == "private_browser" else schema
|
||||||
|
for schema in FUNCTION_TOOL_SCHEMAS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Converter: native function call -> ToolBlock
|
# Converter: native function call -> ToolBlock
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ PLAN_MODE_READONLY_TOOLS = {
|
|||||||
"resolve_contact",
|
"resolve_contact",
|
||||||
"chat_with_model",
|
"chat_with_model",
|
||||||
"ask_teacher",
|
"ask_teacher",
|
||||||
|
"get_editor_screenshot",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -179,6 +180,7 @@ _PLAN_MODE_KNOWN_MUTATORS = {
|
|||||||
"download_model", "serve_model",
|
"download_model", "serve_model",
|
||||||
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
|
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
|
||||||
"generate_image", "edit_image", "trigger_research", "manage_research",
|
"generate_image", "edit_image", "trigger_research", "manage_research",
|
||||||
|
"plan_edit", "run_editor_command", "ai_edit_image",
|
||||||
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
||||||
# mode even if the schema list fails to load.
|
# mode even if the schema list fails to load.
|
||||||
"bash", "python",
|
"bash", "python",
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ TOOL_TAGS = {
|
|||||||
"tail_serve_output", "list_downloads", "cancel_download", "search_hf_models",
|
"tail_serve_output", "list_downloads", "cancel_download", "search_hf_models",
|
||||||
"list_cached_models", "list_serve_presets", "serve_preset", "adopt_served_model",
|
"list_cached_models", "list_serve_presets", "serve_preset", "adopt_served_model",
|
||||||
"list_cookbook_servers", "edit_image", "trigger_research", "manage_research",
|
"list_cookbook_servers", "edit_image", "trigger_research", "manage_research",
|
||||||
|
"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image",
|
||||||
"app_api",
|
"app_api",
|
||||||
} | BUILTIN_EMAIL_TOOLS
|
} | BUILTIN_EMAIL_TOOLS
|
||||||
|
|||||||
+13
-3
@@ -16,7 +16,7 @@ from typing import Dict, Optional
|
|||||||
from src.tools._common import _parse_tool_args
|
from src.tools._common import _parse_tool_args
|
||||||
|
|
||||||
|
|
||||||
async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
async def do_edit_image(content: str, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||||
"""Create an owner-scoped edited copy of a gallery image."""
|
"""Create an owner-scoped edited copy of a gallery image."""
|
||||||
try:
|
try:
|
||||||
args = _parse_tool_args(content)
|
args = _parse_tool_args(content)
|
||||||
@@ -45,7 +45,7 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
if not load_settings().get('image_gen_enabled', True):
|
if not load_settings().get('image_gen_enabled', True):
|
||||||
return {'error': 'Image generation is disabled by the administrator.', 'exit_code': 1}
|
return {'error': 'Image generation is disabled by the administrator.', 'exit_code': 1}
|
||||||
from src.ai_interaction import do_edit_image as edit_with_model
|
from src.ai_interaction import do_edit_image as edit_with_model
|
||||||
return await edit_with_model(str(args.get('prompt') or '').strip(), info['path'], owner=owner, size='auto')
|
return await edit_with_model(str(args.get('prompt') or '').strip(), info['path'], owner=owner, size='auto', incognito=incognito)
|
||||||
|
|
||||||
from core.database import GalleryImage, SessionLocal
|
from core.database import GalleryImage, SessionLocal
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
@@ -79,7 +79,7 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
session_id = source.session_id
|
session_id = source.session_id
|
||||||
db.close()
|
db.close()
|
||||||
from src.ai_interaction import do_edit_image as edit_with_model
|
from src.ai_interaction import do_edit_image as edit_with_model
|
||||||
return await edit_with_model(prompt, str(source_path), session_id=session_id, owner=owner, size='auto')
|
return await edit_with_model(prompt, str(source_path), session_id=session_id, owner=owner, size='auto', incognito=incognito)
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
@@ -111,6 +111,16 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
output_bytes = output.getvalue()
|
output_bytes = output.getvalue()
|
||||||
width, height = image.size
|
width, height = image.size
|
||||||
|
|
||||||
|
if incognito:
|
||||||
|
import base64
|
||||||
|
return {
|
||||||
|
'output': f'Image edited ({action}). Private result, not saved to gallery.',
|
||||||
|
'exit_code': 0, 'image_id': None,
|
||||||
|
'image_url': 'data:image/png;base64,' + base64.b64encode(output_bytes).decode('ascii'),
|
||||||
|
'image_prompt': source.prompt or action, 'image_model': f'edit_image:{action}',
|
||||||
|
'image_size': f'{width}x{height}', 'image_quality': source.quality or '',
|
||||||
|
}
|
||||||
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
(root / filename).write_bytes(output_bytes)
|
(root / filename).write_bytes(output_bytes)
|
||||||
|
|||||||
+26
-3
@@ -37,6 +37,7 @@ FAMILY_TOOLS = {
|
|||||||
"sessions": frozenset({"list_sessions", "manage_session", "create_session", "send_to_session", "chat_with_model", "pipeline"}),
|
"sessions": frozenset({"list_sessions", "manage_session", "create_session", "send_to_session", "chat_with_model", "pipeline"}),
|
||||||
"image_generation": frozenset({"generate_image"}),
|
"image_generation": frozenset({"generate_image"}),
|
||||||
"image_editing": frozenset({"edit_image"}),
|
"image_editing": frozenset({"edit_image"}),
|
||||||
|
"photo_editor": frozenset({"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image", "generate_image"}),
|
||||||
"transcription": frozenset({"transcribe_media"}),
|
"transcription": frozenset({"transcribe_media"}),
|
||||||
"media_inspection": frozenset({"inspect_media"}),
|
"media_inspection": frozenset({"inspect_media"}),
|
||||||
"ocr": frozenset({"extract_text"}),
|
"ocr": frozenset({"extract_text"}),
|
||||||
@@ -487,7 +488,8 @@ _BOUND_EDITOR_WRITE = re.compile(
|
|||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
_BOUND_EDITOR_IMPLICIT_REVISION = re.compile(
|
_BOUND_EDITOR_IMPLICIT_REVISION = re.compile(
|
||||||
r"^\s*" + _REQUEST_PREFIX + r"(?:broaden|expand|deepen|lighten|go\s+deeper|"
|
r"^\s*" + _REQUEST_PREFIX + r"(?:broaden|expand|deepen|lighten|go\s+deeper|go\s+more\s+into|"
|
||||||
|
r"g[aå]\s+mer\s+(?:in|ut)\s+p[aå]|"
|
||||||
r"clean\s+(?:this|it|the\s+(?:text|draft|document|doc))\s+up|"
|
r"clean\s+(?:this|it|the\s+(?:text|draft|document|doc))\s+up|"
|
||||||
r"give\s+(?:me\s+)?(?:feedback|a\s+critique|suggestions?|comments?)|"
|
r"give\s+(?:me\s+)?(?:feedback|a\s+critique|suggestions?|comments?)|"
|
||||||
r"remove\b[^.;\n]{0,100}\b(?:mistakes?|errors?|inaccurac(?:y|ies)|misinformation)|"
|
r"remove\b[^.;\n]{0,100}\b(?:mistakes?|errors?|inaccurac(?:y|ies)|misinformation)|"
|
||||||
@@ -633,6 +635,7 @@ _REQUIRED_TOOLS = {
|
|||||||
"calendar": "manage_calendar", "notes": "manage_notes",
|
"calendar": "manage_calendar", "notes": "manage_notes",
|
||||||
"tasks": "manage_tasks", "skills": "manage_skills",
|
"tasks": "manage_tasks", "skills": "manage_skills",
|
||||||
"image_generation": "generate_image", "image_editing": "edit_image",
|
"image_generation": "generate_image", "image_editing": "edit_image",
|
||||||
|
"photo_editor": "plan_edit",
|
||||||
"transcription": "transcribe_media", "media_inspection": "inspect_media", "ocr": "extract_text",
|
"transcription": "transcribe_media", "media_inspection": "inspect_media", "ocr": "extract_text",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2994,6 +2997,18 @@ def _natural_safe_inventory_operation(message: str, maximum: int | None = None)
|
|||||||
return RequiredReadOperation(tool, {"action": action} if action else {}, maximum)
|
return RequiredReadOperation(tool, {"action": action} if action else {}, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
def note_read_intent(user_text: str) -> tuple[str, bool]:
|
||||||
|
"""Return a quoted mention query and whether the note body is needed."""
|
||||||
|
text = str(user_text or "")
|
||||||
|
mention = re.search(r"\bmentions?\s+(['\"])([^'\"]{1,120})\1", text, re.I)
|
||||||
|
query = mention.group(2).strip() if mention else ""
|
||||||
|
needs_body = bool(
|
||||||
|
re.search(r"\b(?:summari[sz]e|summary|main\s+topics)\b", text, re.I)
|
||||||
|
or re.search(r"\b(?:show|open|read|view)\b.{0,80}\b(?:first|newest|most\s+recent)\s+note\b", text, re.I)
|
||||||
|
)
|
||||||
|
return query, needs_body
|
||||||
|
|
||||||
|
|
||||||
def required_read_operation_for_request(message: str, history: Iterable = ()) -> RequiredReadOperation | None:
|
def required_read_operation_for_request(message: str, history: Iterable = ()) -> RequiredReadOperation | None:
|
||||||
"""Resolve complete list/read requests, or repeat an exact prior read.
|
"""Resolve complete list/read requests, or repeat an exact prior read.
|
||||||
|
|
||||||
@@ -4149,7 +4164,15 @@ def _families_for_tool(tool: str) -> frozenset[str]:
|
|||||||
if bare in {"list_sessions", "manage_session", "create_session", "send_to_session",
|
if bare in {"list_sessions", "manage_session", "create_session", "send_to_session",
|
||||||
"chat_with_model", "pipeline"}:
|
"chat_with_model", "pipeline"}:
|
||||||
return frozenset({"sessions"})
|
return frozenset({"sessions"})
|
||||||
return frozenset(family for family, tools in FAMILY_TOOLS.items() if bare in tools)
|
# The photo_editor family is context-only: it is only ever selected by an
|
||||||
|
# explicit editor request at the route, never inferred from message text.
|
||||||
|
# Overlapping tools such as generate_image must still resolve to their real
|
||||||
|
# product family, so exclude photo_editor rather than short-circuiting.
|
||||||
|
return frozenset(
|
||||||
|
family
|
||||||
|
for family, tools in FAMILY_TOOLS.items()
|
||||||
|
if bare in tools and family != "photo_editor"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clause_capabilities(text: str) -> set[str]:
|
def _clause_capabilities(text: str) -> set[str]:
|
||||||
@@ -6325,7 +6348,7 @@ def resolve_full_inventory_contract(*, schemas: Iterable[dict], policy: ToolPoli
|
|||||||
"""Experimental trained inventory: permissions filter offers; model chooses actions."""
|
"""Experimental trained inventory: permissions filter offers; model chooses actions."""
|
||||||
families = frozenset({"calendar", "notes", "tasks", "skills", "memory", "documents",
|
families = frozenset({"calendar", "notes", "tasks", "skills", "memory", "documents",
|
||||||
"email", "search_browser", "shell_files", "cookbook_admin",
|
"email", "search_browser", "shell_files", "cookbook_admin",
|
||||||
"image_editing", "image_generation"})
|
"image_editing", "image_generation", "photo_editor"})
|
||||||
# ``ui_control`` is the executable bridge for explicit client-interface
|
# ``ui_control`` is the executable bridge for explicit client-interface
|
||||||
# requests (for example, opening the gallery). It is not one of the ten
|
# requests (for example, opening the gallery). It is not one of the ten
|
||||||
# persisted-data families, but omitting it here makes the full-inventory
|
# persisted-data families, but omitting it here makes the full-inventory
|
||||||
|
|||||||
@@ -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: 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'); }
|
@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) */
|
/* 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: 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'); }
|
@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 */
|
/* Code block baseline */
|
||||||
pre, code, .hljs {
|
pre, code, .hljs {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -7230,6 +7230,39 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
text-align: center;
|
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 */
|
||||||
.setup-wizard { padding: 16px; max-width: 500px; }
|
.setup-wizard { padding: 16px; max-width: 500px; }
|
||||||
.setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); }
|
.setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); }
|
||||||
|
|||||||
@@ -22,6 +22,20 @@
|
|||||||
|
|
||||||
/* Session items */
|
/* Session items */
|
||||||
.session-item { position: relative; }
|
.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; }
|
.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 { 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; }
|
.session-menu-btn:hover { background: none !important; border-color: transparent !important; }
|
||||||
|
|||||||
@@ -704,6 +704,7 @@ textarea.memory-add-input {
|
|||||||
#serve-bulk-bar,
|
#serve-bulk-bar,
|
||||||
#gallery-bulk-bar,
|
#gallery-bulk-bar,
|
||||||
#gallery-editor-drafts-bulk,
|
#gallery-editor-drafts-bulk,
|
||||||
|
#notes-bulk-bar,
|
||||||
#memory-modal .memory-bulk-bar {
|
#memory-modal .memory-bulk-bar {
|
||||||
padding-right: 18px;
|
padding-right: 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,12 @@
|
|||||||
.lib-tabs,
|
.lib-tabs,
|
||||||
.gallery-tabs,
|
.gallery-tabs,
|
||||||
.preset-tabs {
|
.preset-tabs {
|
||||||
|
/* A flex-item tab strip defaults to min-width:auto, so it grows to its
|
||||||
|
content width inside a narrow modal and the end tabs get clipped by the
|
||||||
|
modal's overflow instead of scrolling. min-width:0 lets the strip shrink
|
||||||
|
to the container so the overflow-x:auto below actually engages. */
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: nowrap !important;
|
||||||
overflow-x: auto !important;
|
overflow-x: auto !important;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
|||||||
+4997
-94
File diff suppressed because it is too large
Load Diff
@@ -978,3 +978,343 @@ body.doc-find-active mark.doc-find-mark.current {
|
|||||||
color: var(--accent, var(--red));
|
color: var(--accent, var(--red));
|
||||||
vertical-align: -2px;
|
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.
+3
-2
@@ -257,7 +257,7 @@
|
|||||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261017titlefade1">
|
||||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
||||||
@@ -664,6 +664,7 @@
|
|||||||
<div class="theme-fd-group">
|
<div class="theme-fd-group">
|
||||||
<label class="theme-fd-label">Font</label>
|
<label class="theme-fd-label">Font</label>
|
||||||
<select id="theme-font-select" class="theme-fd-select" aria-label="Font">
|
<select id="theme-font-select" class="theme-fd-select" aria-label="Font">
|
||||||
|
<option value="gohu">GohuFont</option>
|
||||||
<option value="mono">Monospace</option>
|
<option value="mono">Monospace</option>
|
||||||
<option value="sans">Sans-serif</option>
|
<option value="sans">Sans-serif</option>
|
||||||
<option value="serif">Serif</option>
|
<option value="serif">Serif</option>
|
||||||
@@ -2686,7 +2687,7 @@
|
|||||||
<script type="module" src="/static/js/gallery.js?v=20260910promptcopy1"></script>
|
<script type="module" src="/static/js/gallery.js?v=20260910promptcopy1"></script>
|
||||||
<script type="module" src="/static/js/chatRenderer.js?v=20260914metricssummary1"></script>
|
<script type="module" src="/static/js/chatRenderer.js?v=20260914metricssummary1"></script>
|
||||||
<script type="module" src="/static/js/codeRunner.js?v=20260831richtexttools91"></script>
|
<script type="module" src="/static/js/codeRunner.js?v=20260831richtexttools91"></script>
|
||||||
<script type="module" src="/static/js/chatStream.js?v=20260914pdfstrip1"></script>
|
<script type="module" src="/static/js/chatStream.js?v=20261026devsync1"></script>
|
||||||
<script type="module" src="/static/js/chat.js?v=20260917toolttft1"></script>
|
<script type="module" src="/static/js/chat.js?v=20260917toolttft1"></script>
|
||||||
<script type="module" src="/static/js/cookbook.js"></script>
|
<script type="module" src="/static/js/cookbook.js"></script>
|
||||||
<script src="/static/js/cookbookSchedule.js"></script>
|
<script src="/static/js/cookbookSchedule.js"></script>
|
||||||
|
|||||||
+61
-1
@@ -18,7 +18,13 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Click-as-button rows we want reachable by keyboard.
|
// 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
|
// 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
|
// NOT give the row role="button" — a button inside a button is invalid
|
||||||
@@ -55,6 +61,31 @@
|
|||||||
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
|
(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 -----------------------------------------------------
|
// ---- Modal dialogs -----------------------------------------------------
|
||||||
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
|
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
|
||||||
// them as ARIA dialogs lets screen readers announce them as dialogs and
|
// them as ARIA dialogs lets screen readers announce them as dialogs and
|
||||||
@@ -118,9 +149,38 @@
|
|||||||
el.click();
|
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() {
|
function init() {
|
||||||
enhanceAll(document);
|
enhanceAll(document);
|
||||||
enhanceModals(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,
|
// Sidebar content is re-rendered as the user navigates (session lists,
|
||||||
// tool sub-rows, etc.). Watch for new rows and enhance them too.
|
// 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));
|
const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b));
|
||||||
enabledListEl.innerHTML = sortedIds.map(id => {
|
enabledListEl.innerHTML = sortedIds.map(id => {
|
||||||
const displayName = id.split('/').pop() || 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()
|
? String(toolModes[id]).toLowerCase()
|
||||||
: '';
|
: '';
|
||||||
return `<div title="${esc(id)}" data-ep-model-row data-model-id="${esc(id)}" class="adm-model-row">
|
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] || '')}">
|
<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="" ${mode === '' ? 'selected' : ''}>Auto</option>
|
||||||
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</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>
|
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1356,7 +1357,7 @@ async function loadEndpoints() {
|
|||||||
<a href="#" data-ep-select-none="${epId}">None</a>
|
<a href="#" data-ep-select-none="${epId}">None</a>
|
||||||
</span>
|
</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 => {
|
</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()
|
? 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">
|
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">
|
<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="" ${mode === '' ? 'selected' : ''}>Auto</option>
|
||||||
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</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>
|
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1440,7 +1442,7 @@ async function _saveEpModelState(epId, panel) {
|
|||||||
const value = String(sel.value || '').toLowerCase();
|
const value = String(sel.value || '').toLowerCase();
|
||||||
const original = String(sel.dataset.originalToolMode || '').toLowerCase();
|
const original = String(sel.dataset.originalToolMode || '').toLowerCase();
|
||||||
if (modelId && (sel.dataset.toolModeTouched === '1' || original)) {
|
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;
|
const total = panel.querySelectorAll('input.adm-cb-hidden[type=checkbox]').length;
|
||||||
|
|||||||
+72
-2
@@ -9,7 +9,7 @@ import Storage from './storage.js';
|
|||||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||||
import sessionModule from './sessions.js';
|
import sessionModule from './sessions.js';
|
||||||
import chatRenderer, { renderToolIcon } from './chatRenderer.js?v=20260914metricssummary1';
|
import chatRenderer, { renderToolIcon } from './chatRenderer.js?v=20260914metricssummary1';
|
||||||
import chatStream from './chatStream.js?v=20260914pdfstrip1';
|
import chatStream from './chatStream.js?v=20261026devsync1';
|
||||||
import { addAITTSButton } from './tts-ai.js';
|
import { addAITTSButton } from './tts-ai.js';
|
||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
@@ -44,6 +44,66 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
|
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
|
||||||
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
||||||
|
|
||||||
|
// URL of the photo attached to the current turn (chat uploads are served at
|
||||||
|
// /api/upload/<id>). Used by the "Open in editor & apply" action.
|
||||||
|
function _currentAttachedImageUrl() {
|
||||||
|
try {
|
||||||
|
const box = document.getElementById('chat-history');
|
||||||
|
if (!box) return null;
|
||||||
|
// Use the newest user bubble that carries an image preview. Note
|
||||||
|
// ':last-of-type' is wrong here (it selects the last sibling of the
|
||||||
|
// element type, and the assistant reply follows the user bubble), so
|
||||||
|
// walk the user bubbles from the end instead.
|
||||||
|
const users = box.querySelectorAll('.msg-user');
|
||||||
|
for (let i = users.length - 1; i >= 0; i--) {
|
||||||
|
const img = users[i].querySelector('.attach-image-preview[data-file-id] img');
|
||||||
|
if (img && img.getAttribute('src')) return img.getAttribute('src');
|
||||||
|
}
|
||||||
|
// Fall back to any attachment preview/card in the history (newest).
|
||||||
|
const previews = box.querySelectorAll(
|
||||||
|
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
|
||||||
|
);
|
||||||
|
if (previews.length) {
|
||||||
|
const last = previews[previews.length - 1];
|
||||||
|
const id = last.dataset ? last.dataset.fileId : null;
|
||||||
|
if (id) return API_BASE + '/api/upload/' + id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the one-click "Open in editor & apply" button on a photo-edit reply
|
||||||
|
// that had no open editor (photo_editor_action kind 'open_and_apply').
|
||||||
|
function renderPhotoEditorApplyButton(container, action) {
|
||||||
|
if (!container || !action || action.kind !== 'open_and_apply') return;
|
||||||
|
if (container.querySelector('.chat-open-in-editor-btn')) return;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'chat-open-in-editor-btn';
|
||||||
|
btn.textContent = 'Open in editor & apply';
|
||||||
|
btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;';
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (typeof window.gePhotoEditorApply !== 'function') {
|
||||||
|
await import('/static/js/galleryEditor.js?v=20260909movepicklayer1');
|
||||||
|
}
|
||||||
|
await window.gePhotoEditorApply({
|
||||||
|
imageId: null,
|
||||||
|
imageUrl: _currentAttachedImageUrl(),
|
||||||
|
prompt: action.prompt || '',
|
||||||
|
displayName: 'Chat edit',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[chat] open_and_apply failed', err);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
let API_BASE = '';
|
let API_BASE = '';
|
||||||
let currentAbort = null;
|
let currentAbort = null;
|
||||||
let isStreaming = false;
|
let isStreaming = false;
|
||||||
@@ -4095,6 +4155,7 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
threadAbove.classList.add('has-bottom');
|
threadAbove.classList.add('has-bottom');
|
||||||
}
|
}
|
||||||
terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope);
|
terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope);
|
||||||
|
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
|
||||||
lastContentRoundHolder = roundHolder || lastContentRoundHolder;
|
lastContentRoundHolder = roundHolder || lastContentRoundHolder;
|
||||||
uiModule.scrollHistory();
|
uiModule.scrollHistory();
|
||||||
continue;
|
continue;
|
||||||
@@ -6149,7 +6210,15 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!_getForegroundStreamState() || _backgroundStreams.has(sid)) return;
|
if (!_getForegroundStreamState() || _backgroundStreams.has(sid)) return;
|
||||||
if (res.status !== 404) return;
|
// The server now reports "no active stream" as 200 {status:"idle"} (an
|
||||||
|
// older server used 404); a body with status "streaming" means it is NOT
|
||||||
|
// stale. Accept both so the watchdog keeps working across deploys.
|
||||||
|
if (res.status !== 404 && !res.ok) return;
|
||||||
|
let _probe = null;
|
||||||
|
try { _probe = await res.json(); } catch (_) { _probe = null; }
|
||||||
|
const _noActiveStream = res.status === 404 || (res.ok && _probe &&
|
||||||
|
typeof _probe.status === 'string' && _probe.status !== 'streaming');
|
||||||
|
if (!_noActiveStream) return;
|
||||||
|
|
||||||
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
|
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
|
||||||
if (active.abortCtrl && !active.abortCtrl.signal.aborted) {
|
if (active.abortCtrl && !active.abortCtrl.signal.aborted) {
|
||||||
@@ -6511,6 +6580,7 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
gotDelta = true;
|
gotDelta = true;
|
||||||
try { spinner.destroy(); } catch (_) {}
|
try { spinner.destroy(); } catch (_) {}
|
||||||
renderDelta(json);
|
renderDelta(json);
|
||||||
|
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
|
||||||
replayRendering.settle();
|
replayRendering.settle();
|
||||||
} else if (json.delta) {
|
} else if (json.delta) {
|
||||||
if (!replayRendering.accepts(json)) continue;
|
if (!replayRendering.accepts(json)) continue;
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ function _makeActionBtn(className, title, text, handler) {
|
|||||||
btn.className = className;
|
btn.className = className;
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.title = title;
|
btn.title = title;
|
||||||
|
btn.setAttribute('aria-label', title);
|
||||||
btn.textContent = text;
|
btn.textContent = text;
|
||||||
btn.addEventListener('click', handler);
|
btn.addEventListener('click', handler);
|
||||||
return btn;
|
return btn;
|
||||||
@@ -2375,6 +2376,7 @@ export function createMsgFooter(msgElement, { animate = false } = {}) {
|
|||||||
moreBtn.className = 'msg-action-btn msg-more-btn';
|
moreBtn.className = 'msg-action-btn msg-more-btn';
|
||||||
moreBtn.type = 'button';
|
moreBtn.type = 'button';
|
||||||
moreBtn.title = 'More actions';
|
moreBtn.title = 'More actions';
|
||||||
|
moreBtn.setAttribute('aria-label', 'More actions');
|
||||||
moreBtn.textContent = '\u00B7\u00B7\u00B7';
|
moreBtn.textContent = '\u00B7\u00B7\u00B7';
|
||||||
moreBtn.addEventListener('click', (e) => {
|
moreBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -2571,6 +2573,7 @@ export function createUserMsgFooter(msgElement) {
|
|||||||
moreBtn.className = 'msg-action-btn msg-more-btn';
|
moreBtn.className = 'msg-action-btn msg-more-btn';
|
||||||
moreBtn.type = 'button';
|
moreBtn.type = 'button';
|
||||||
moreBtn.title = 'More actions';
|
moreBtn.title = 'More actions';
|
||||||
|
moreBtn.setAttribute('aria-label', 'More actions');
|
||||||
moreBtn.textContent = '\u00B7\u00B7\u00B7';
|
moreBtn.textContent = '\u00B7\u00B7\u00B7';
|
||||||
moreBtn.addEventListener('click', (e) => {
|
moreBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -3274,6 +3277,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
let lastWrap = null;
|
let lastWrap = null;
|
||||||
let firstMsgAi = null;
|
let firstMsgAi = null;
|
||||||
let lastMsgAi = null;
|
let lastMsgAi = null;
|
||||||
|
const newNodes = [];
|
||||||
|
|
||||||
const toolsByRound = {};
|
const toolsByRound = {};
|
||||||
let lastPrivateBrowserUrl = '';
|
let lastPrivateBrowserUrl = '';
|
||||||
@@ -3367,6 +3371,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
wrap.dataset.raw = txt;
|
wrap.dataset.raw = txt;
|
||||||
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
|
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
|
||||||
box.appendChild(wrap);
|
box.appendChild(wrap);
|
||||||
|
newNodes.push(wrap);
|
||||||
lastWrap = wrap;
|
lastWrap = wrap;
|
||||||
if (!firstMsgAi) firstMsgAi = wrap;
|
if (!firstMsgAi) firstMsgAi = wrap;
|
||||||
lastMsgAi = wrap;
|
lastMsgAi = wrap;
|
||||||
@@ -3384,6 +3389,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
// Extend line up if there's a chat bubble above
|
// Extend line up if there's a chat bubble above
|
||||||
if (txt) threadWrap.classList.add('has-top');
|
if (txt) threadWrap.classList.add('has-top');
|
||||||
box.appendChild(threadWrap);
|
box.appendChild(threadWrap);
|
||||||
|
newNodes.push(threadWrap);
|
||||||
}
|
}
|
||||||
for (const ev of roundTools) {
|
for (const ev of roundTools) {
|
||||||
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
|
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
|
||||||
@@ -3443,7 +3449,9 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
for (const ev of roundTools) {
|
for (const ev of roundTools) {
|
||||||
const image = generatedImageResult(ev);
|
const image = generatedImageResult(ev);
|
||||||
if (image) {
|
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);
|
||||||
|
newNodes.push(imgBubble);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ export function handleUIControl(uiData) {
|
|||||||
var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes));
|
var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes));
|
||||||
if (fn) fn();
|
if (fn) fn();
|
||||||
}).catch(function(){});
|
}).catch(function(){});
|
||||||
|
} else if (panel === 'tasks') {
|
||||||
|
document.getElementById('tool-tasks-btn')?.click();
|
||||||
} else if (panel === 'theme' || panel === 'themes') {
|
} else if (panel === 'theme' || panel === 'themes') {
|
||||||
import('./theme.js?v=20260911organsrain1').then(function(mod) {
|
import('./theme.js?v=20260911organsrain1').then(function(mod) {
|
||||||
var fn = mod.togglePopup || (mod.default && mod.default.togglePopup);
|
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
|
* Main entry point — called when a Run button is clicked
|
||||||
*/
|
*/
|
||||||
export function run(btn) {
|
export function run(btn) {
|
||||||
const code = btn.getAttribute('data-code');
|
|
||||||
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
|
|
||||||
|
|
||||||
const pre = btn.closest('pre');
|
const pre = btn.closest('pre');
|
||||||
if (!pre) return;
|
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);
|
const panel = getOrCreatePanel(pre);
|
||||||
if (!code || !code.trim()) {
|
if (!code || !code.trim()) {
|
||||||
|
|||||||
@@ -96,6 +96,21 @@ function computeSuggestions() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Reusable helpers for hosts that surface swatches inline ──────────
|
||||||
|
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
|
||||||
|
export function getRecentColors() { return getRecents(); }
|
||||||
|
export function recordRecentColor(hex) { addRecent(hex); }
|
||||||
|
export function suggestColorsFor(hex) {
|
||||||
|
const { h, s, v } = hexToHsv(hex || '#000000');
|
||||||
|
return [
|
||||||
|
{ hex: hsvToHex(h + 180, s, v), label: 'Complement' },
|
||||||
|
{ hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
|
||||||
|
{ hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
|
||||||
|
{ hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
|
||||||
|
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Popover build ─────────────────────────────────────────────────────
|
// ── Popover build ─────────────────────────────────────────────────────
|
||||||
function buildPopover() {
|
function buildPopover() {
|
||||||
const p = document.createElement('div');
|
const p = document.createElement('div');
|
||||||
|
|||||||
@@ -1498,7 +1498,8 @@ function _rerenderCachedModels() {
|
|||||||
const _ggufOptions = _ggufChoices.map(f =>
|
const _ggufOptions = _ggufChoices.map(f =>
|
||||||
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
|
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
|
||||||
).join('');
|
).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 _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
|
||||||
const _modelPathValue = _savedModelPath || _defaultServeModel;
|
const _modelPathValue = _savedModelPath || _defaultServeModel;
|
||||||
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
|
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
|
||||||
|
|||||||
+33
-1
@@ -31,10 +31,15 @@ export function enable(containerId, itemSelector, options = {}) {
|
|||||||
onReorder: options.onReorder || null,
|
onReorder: options.onReorder || null,
|
||||||
handleSelector: options.handleSelector || null,
|
handleSelector: options.handleSelector || null,
|
||||||
excludeSelector: options.excludeSelector || 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,
|
storageKey: options.storageKey || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
let draggedEl = null;
|
let draggedEl = null;
|
||||||
|
let pending = null;
|
||||||
let placeholder = null;
|
let placeholder = null;
|
||||||
let offsetY = 0;
|
let offsetY = 0;
|
||||||
let items = [];
|
let items = [];
|
||||||
@@ -130,6 +135,12 @@ export function enable(containerId, itemSelector, options = {}) {
|
|||||||
draggedEl.style.top = snapTop + 'px';
|
draggedEl.style.top = snapTop + 'px';
|
||||||
|
|
||||||
setTimeout(() => {
|
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.parentNode.insertBefore(draggedEl, placeholder);
|
||||||
placeholder.remove();
|
placeholder.remove();
|
||||||
draggedEl.classList.remove('dragging');
|
draggedEl.classList.remove('dragging');
|
||||||
@@ -158,10 +169,21 @@ export function enable(containerId, itemSelector, options = {}) {
|
|||||||
function onMouseDown(e) {
|
function onMouseDown(e) {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (config.handleSelector && !e.target.closest(config.handleSelector)) 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);
|
const item = e.target.closest(itemSelector);
|
||||||
if (!item || !container.contains(item)) return;
|
if (!item || !container.contains(item)) return;
|
||||||
if (config.excludeSelector && item.matches(config.excludeSelector)) 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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
startDrag(e.clientY, item);
|
startDrag(e.clientY, item);
|
||||||
@@ -169,11 +191,21 @@ export function enable(containerId, itemSelector, options = {}) {
|
|||||||
document.addEventListener('mouseup', onMouseUp);
|
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() {
|
function onMouseUp() {
|
||||||
document.removeEventListener('mousemove', onMouseMove);
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
document.removeEventListener('mouseup', onMouseUp);
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
|
pending = null;
|
||||||
endDrag();
|
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 { applyAdjustment } from './fx/pixel-pass.js';
|
||||||
import { defaultAdjParams } from './layer-helpers.js';
|
import { defaultAdjParams } from './layer-helpers.js';
|
||||||
import { renderWithLayerMasks } from './composite-helpers.js';
|
import { renderWithLayerMasks } from './composite-helpers.js';
|
||||||
|
import { blendDraw } from './blend-modes.js';
|
||||||
|
|
||||||
const TYPES = new Set([
|
const TYPES = new Set([
|
||||||
'brightness-contrast',
|
'brightness-contrast',
|
||||||
'exposure',
|
'exposure',
|
||||||
|
'develop',
|
||||||
'white-balance',
|
'white-balance',
|
||||||
'hue-saturation',
|
'hue-saturation',
|
||||||
'vibrance',
|
'vibrance',
|
||||||
@@ -16,6 +18,12 @@ const TYPES = new Set([
|
|||||||
'color-balance',
|
'color-balance',
|
||||||
'selective-color',
|
'selective-color',
|
||||||
'gradient-map',
|
'gradient-map',
|
||||||
|
'invert',
|
||||||
|
'posterize',
|
||||||
|
'threshold',
|
||||||
|
'photo-filter',
|
||||||
|
'channel-mixer',
|
||||||
|
'color-lookup',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function clone(value) {
|
function clone(value) {
|
||||||
@@ -52,6 +60,11 @@ export function normalizeAdjustmentData(value) {
|
|||||||
params.ranges[name] = { ...clone(defaults.ranges[name]), ...(params.ranges[name] || {}) };
|
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 };
|
return { type, params };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +122,8 @@ export function drawAdjustmentLayer(target, editorState, layer, clippingBase, re
|
|||||||
adjusted = clipped;
|
adjusted = clipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
||||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0);
|
||||||
target.drawImage(adjusted, 0, 0);
|
|
||||||
target.globalAlpha = 1;
|
|
||||||
target.globalCompositeOperation = 'source-over';
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +164,8 @@ export async function drawAdjustmentLayerAsync(target, editorState, layer, clipp
|
|||||||
clippedCtx.globalCompositeOperation = 'source-over';
|
clippedCtx.globalCompositeOperation = 'source-over';
|
||||||
adjusted = clipped;
|
adjusted = clipped;
|
||||||
}
|
}
|
||||||
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
||||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0);
|
||||||
target.drawImage(adjusted, 0, 0);
|
|
||||||
target.globalAlpha = 1;
|
|
||||||
target.globalCompositeOperation = 'source-over';
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,12 @@
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { sortModelIds } from '../modelSort.js';
|
import { sortModelIds } from '../modelSort.js';
|
||||||
|
|
||||||
|
// Last result of loadAIModels() — consumed by the editor setup checklist
|
||||||
|
// (static/js/editor/preflight.js) to decide whether an image provider is
|
||||||
|
// reachable and which image models are available.
|
||||||
|
let _aiModelsStatus = { ready: false, models: [], error: null, at: 0 };
|
||||||
|
export function getAIModelsStatus() { return _aiModelsStatus; }
|
||||||
|
|
||||||
export function resolveInpaintModel(select) {
|
export function resolveInpaintModel(select) {
|
||||||
const options = Array.from(select?.options || []).filter(option =>
|
const options = Array.from(select?.options || []).filter(option =>
|
||||||
!option.disabled && option.value && option.value !== '__serve_cookbook__');
|
!option.disabled && option.value && option.value !== '__serve_cookbook__');
|
||||||
@@ -34,6 +40,71 @@ export function resolveInpaintModel(select) {
|
|||||||
return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null;
|
return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the model the Quick Edit box will use. Prefers the explicit Quick
|
||||||
|
// Edit picker (#ge-ai-command-model); falls back to the inpaint picker / Auto.
|
||||||
|
export function resolveQuickEditModel() {
|
||||||
|
const sel = document.getElementById('ge-ai-command-model');
|
||||||
|
if (sel && sel.value && sel.value !== '__serve_cookbook__') {
|
||||||
|
const opt = sel.options[sel.selectedIndex];
|
||||||
|
const raw = sel.value || '';
|
||||||
|
const idx = raw.indexOf('::');
|
||||||
|
return {
|
||||||
|
endpoint: idx < 0 ? raw : raw.slice(0, idx),
|
||||||
|
model: idx < 0 ? '' : raw.slice(idx + 2),
|
||||||
|
label: ((opt && opt.textContent) || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const inp = document.getElementById('ge-ai-inpaint');
|
||||||
|
const opt = resolveInpaintModel(inp);
|
||||||
|
if (!opt) return { endpoint: '', model: '', label: '' };
|
||||||
|
const raw = opt.value || '';
|
||||||
|
const idx = raw.indexOf('::');
|
||||||
|
return {
|
||||||
|
endpoint: idx < 0 ? raw : raw.slice(0, idx),
|
||||||
|
model: idx < 0 ? '' : raw.slice(idx + 2),
|
||||||
|
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
|
||||||
|
// that reads the canvas screenshot and plans the edit. Default is the local
|
||||||
|
// Qwen3.5-9B VL server on seneca; "Server default" keeps the HARNESS planner.
|
||||||
|
export function resolveQuickEditAgentModel() {
|
||||||
|
const sel = document.getElementById('ge-ai-command-agent-model');
|
||||||
|
const raw = (sel && sel.value) || '';
|
||||||
|
if (!raw || raw === 'default') {
|
||||||
|
return { agent: '', endpoint: '', label: 'Server default' };
|
||||||
|
}
|
||||||
|
const idx = raw.indexOf('::');
|
||||||
|
const endpoint = idx < 0 ? '' : raw.slice(0, idx);
|
||||||
|
const agent = idx < 0 ? raw : raw.slice(idx + 2);
|
||||||
|
const opt = sel.options[sel.selectedIndex];
|
||||||
|
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick Edit agent (planner) model options. Kept small + explicit; the local
|
||||||
|
// Qwen3.5-9B VL is the default. `value` encodes "<endpoint>::<model>".
|
||||||
|
export const QUICK_EDIT_AGENT_OPTIONS = [
|
||||||
|
{ value: 'http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B', label: 'Qwen3.5-9B VL · local' },
|
||||||
|
{ value: 'default', label: 'Server default (hosted planner)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
// can see which model Quick Edit / inpaint will call.
|
||||||
|
export function updateFooterEditModel() {
|
||||||
|
const el = document.getElementById('ge-status-model');
|
||||||
|
if (!el) return;
|
||||||
|
const info = resolveQuickEditModel();
|
||||||
|
if (!info.label && !info.model) {
|
||||||
|
el.textContent = 'Edit: none';
|
||||||
|
el.title = 'No AI edit model available';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = info.label || info.model;
|
||||||
|
el.textContent = `Edit: ${label}`;
|
||||||
|
el.title = `AI edit model: ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Heuristic classifier on a model id + endpoint name. A model can be:
|
// Heuristic classifier on a model id + endpoint name. A model can be:
|
||||||
// - gen: text-to-image generation
|
// - gen: text-to-image generation
|
||||||
// - inpaint: image+mask edit (inpaint / img2img)
|
// - inpaint: image+mask edit (inpaint / img2img)
|
||||||
@@ -92,11 +163,40 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
|
|
||||||
const aiGenSelect = document.getElementById('ge-ai-model');
|
const aiGenSelect = document.getElementById('ge-ai-model');
|
||||||
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
|
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
|
||||||
|
const aiCommandSelect = document.getElementById('ge-ai-command-model');
|
||||||
|
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
|
||||||
|
if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
|
||||||
|
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
|
||||||
|
// The Quick Edit agent (planner) picker is static for now (local VL default,
|
||||||
|
// server default option); restore/save its choice from localStorage.
|
||||||
|
const populateAgentSelect = () => {
|
||||||
|
if (!aiCommandAgentSelect) return;
|
||||||
|
aiCommandAgentSelect.innerHTML = '';
|
||||||
|
for (const opt of QUICK_EDIT_AGENT_OPTIONS) {
|
||||||
|
const el = document.createElement('option');
|
||||||
|
el.value = opt.value;
|
||||||
|
el.textContent = opt.label;
|
||||||
|
aiCommandAgentSelect.appendChild(el);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('ge-ai-command-agent-model');
|
||||||
|
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
|
||||||
|
aiCommandAgentSelect.value = saved;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (!aiCommandAgentSelect._agentWired) {
|
||||||
|
aiCommandAgentSelect._agentWired = true;
|
||||||
|
aiCommandAgentSelect.addEventListener('change', () => {
|
||||||
|
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
// The global Gen model dropdown was removed from the editor topbar;
|
// The global Gen model dropdown was removed from the editor topbar;
|
||||||
// only bail if there's nothing to populate at all (neither the Gen
|
// only bail if there's nothing to populate at all (neither the Gen
|
||||||
// select nor the inpaint select nor any per-tool select).
|
// select nor the inpaint select nor any per-tool select).
|
||||||
if (!aiGenSelect && !aiInpaintSelect &&
|
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
|
||||||
!document.querySelector('select.ge-tool-model')) return;
|
!document.querySelector('select.ge-tool-model')) return;
|
||||||
|
populateAgentSelect();
|
||||||
|
|
||||||
async function loadAIModels(opts = {}) {
|
async function loadAIModels(opts = {}) {
|
||||||
try {
|
try {
|
||||||
@@ -108,11 +208,13 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
const endpoints = await res.json();
|
const endpoints = await res.json();
|
||||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
||||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||||
|
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
|
||||||
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
|
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
|
||||||
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
||||||
let firstGen = null;
|
let firstGen = null;
|
||||||
let selectedGen = null;
|
let selectedGen = null;
|
||||||
let selectedInpaint = null;
|
let selectedInpaint = null;
|
||||||
|
const loaded = [];
|
||||||
for (const ep of endpoints) {
|
for (const ep of endpoints) {
|
||||||
if (!ep.is_enabled) continue;
|
if (!ep.is_enabled) continue;
|
||||||
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
|
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
|
||||||
@@ -130,6 +232,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url);
|
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url);
|
||||||
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : '';
|
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : '';
|
||||||
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`;
|
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`;
|
||||||
|
loaded.push({ value, label, gen: caps.gen, inpaint: caps.inpaint, usable: epUsable, endpoint: ep.base_url, model: modelId });
|
||||||
if (caps.gen && aiGenSelect) {
|
if (caps.gen && aiGenSelect) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = value;
|
opt.value = value;
|
||||||
@@ -158,6 +261,13 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
opt.disabled = !epUsable;
|
opt.disabled = !epUsable;
|
||||||
ts.appendChild(opt);
|
ts.appendChild(opt);
|
||||||
}
|
}
|
||||||
|
if (aiCommandSelect) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = value;
|
||||||
|
opt.textContent = label;
|
||||||
|
opt.disabled = !epUsable;
|
||||||
|
aiCommandSelect.appendChild(opt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +283,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' });
|
const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' });
|
||||||
aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)';
|
aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)';
|
||||||
}
|
}
|
||||||
|
updateFooterEditModel();
|
||||||
// Append the "Serve a model in Cookbook…" sentinel at the
|
// Append the "Serve a model in Cookbook…" sentinel at the
|
||||||
// bottom of every model dropdown.
|
// bottom of every model dropdown.
|
||||||
const appendServeSentinel = (sel) => {
|
const appendServeSentinel = (sel) => {
|
||||||
@@ -188,6 +299,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
for (const ts of perToolSelects) appendServeSentinel(ts);
|
for (const ts of perToolSelects) appendServeSentinel(ts);
|
||||||
if (aiGenSelect) appendServeSentinel(aiGenSelect);
|
if (aiGenSelect) appendServeSentinel(aiGenSelect);
|
||||||
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
|
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
|
||||||
|
if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
|
||||||
// Wire the sentinel on the Gen + Inpaint selects too.
|
// Wire the sentinel on the Gen + Inpaint selects too.
|
||||||
const wireServeSentinel = (sel) => {
|
const wireServeSentinel = (sel) => {
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -203,6 +315,18 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
};
|
};
|
||||||
wireServeSentinel(aiGenSelect);
|
wireServeSentinel(aiGenSelect);
|
||||||
wireServeSentinel(aiInpaintSelect);
|
wireServeSentinel(aiInpaintSelect);
|
||||||
|
wireServeSentinel(aiCommandSelect);
|
||||||
|
if (aiCommandSelect) {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('ge-ai-command-model');
|
||||||
|
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) aiCommandSelect.value = saved;
|
||||||
|
} catch {}
|
||||||
|
aiCommandSelect.addEventListener('change', () => {
|
||||||
|
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
||||||
|
try { localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); } catch {}
|
||||||
|
updateFooterEditModel();
|
||||||
|
});
|
||||||
|
}
|
||||||
// Restore each per-tool selection from localStorage.
|
// Restore each per-tool selection from localStorage.
|
||||||
for (const ts of perToolSelects) {
|
for (const ts of perToolSelects) {
|
||||||
const key = 'ge-tool-model-' + ts.dataset.geToolModel;
|
const key = 'ge-tool-model-' + ts.dataset.geToolModel;
|
||||||
@@ -223,6 +347,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
try { localStorage.setItem(key, ts.value); } catch {}
|
try { localStorage.setItem(key, ts.value); } catch {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
|
||||||
|
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fetch failed — still give the user the affordance to set up
|
// Fetch failed — still give the user the affordance to set up
|
||||||
// a model. Otherwise the dropdown shows only "Auto" with no
|
// a model. Otherwise the dropdown shows only "Auto" with no
|
||||||
@@ -230,6 +356,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
const fallback = '<option value="">Auto</option><option disabled>Could not load image models</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
|
const fallback = '<option value="">Auto</option><option disabled>Could not load image models</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
|
||||||
if (aiGenSelect) aiGenSelect.innerHTML = fallback;
|
if (aiGenSelect) aiGenSelect.innerHTML = fallback;
|
||||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
|
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
|
||||||
|
if (aiCommandSelect) aiCommandSelect.innerHTML = fallback;
|
||||||
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
|
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
|
||||||
const wireServe = (sel) => {
|
const wireServe = (sel) => {
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -245,7 +372,12 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
|||||||
};
|
};
|
||||||
wireServe(aiGenSelect);
|
wireServe(aiGenSelect);
|
||||||
wireServe(aiInpaintSelect);
|
wireServe(aiInpaintSelect);
|
||||||
|
wireServe(aiCommandSelect);
|
||||||
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
|
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
|
||||||
|
populateAgentSelect();
|
||||||
|
updateFooterEditModel();
|
||||||
|
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
|
||||||
|
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadAIModels();
|
loadAIModels();
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* AI settings dialog.
|
||||||
|
*
|
||||||
|
* Holds the image-edit model, the AI agent (vision) model, and the opt-in
|
||||||
|
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
|
||||||
|
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
|
||||||
|
*/
|
||||||
|
export function createAiSettings({ container }) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'ge-ai-settings';
|
||||||
|
el.hidden = true;
|
||||||
|
el.setAttribute('role', 'dialog');
|
||||||
|
el.setAttribute('aria-modal', 'true');
|
||||||
|
el.setAttribute('aria-label', 'AI settings');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="ge-ai-settings-card">
|
||||||
|
<div class="ge-ai-settings-head">
|
||||||
|
<h3>AI settings</h3>
|
||||||
|
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="ge-ai-settings-body">
|
||||||
|
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
|
||||||
|
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
|
||||||
|
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
|
||||||
|
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
|
||||||
|
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
|
||||||
|
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
|
||||||
|
<div class="ge-ai-settings-strength">
|
||||||
|
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
|
||||||
|
<span class="ge-ai-settings-strength-val" id="ge-look-strength-val">1.4×</span>
|
||||||
|
</div>
|
||||||
|
<p class="ge-ai-settings-note">How strongly named looks (Kodachrome, teal and orange, …) are graded.</p>
|
||||||
|
<label class="ge-ai-settings-opt" title="Ask the vision model to review the result and retry on a clear failure">
|
||||||
|
<input type="checkbox" id="ge-ai-command-selfcheck" /> Double-check AI edits
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="ge-ai-settings-foot">
|
||||||
|
<button type="button" class="ge-ai-settings-done" data-ai-settings-close>Done</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
function open(focusId) {
|
||||||
|
el.hidden = false;
|
||||||
|
if (focusId) {
|
||||||
|
const target = document.getElementById(focusId);
|
||||||
|
if (target) {
|
||||||
|
try { target.focus({ preventScroll: true }); } catch (_) { target.focus(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function close() { el.hidden = true; }
|
||||||
|
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
if (e.target === el || e.target.closest('[data-ai-settings-close]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); };
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
container.appendChild(el);
|
||||||
|
|
||||||
|
const strengthInput = el.querySelector('#ge-look-strength');
|
||||||
|
const strengthVal = el.querySelector('#ge-look-strength-val');
|
||||||
|
if (strengthInput) {
|
||||||
|
try {
|
||||||
|
const saved = parseFloat(localStorage.getItem('ge-look-strength'));
|
||||||
|
if (Number.isFinite(saved) && saved > 0) strengthInput.value = String(saved);
|
||||||
|
} catch (_) {}
|
||||||
|
if (strengthVal) strengthVal.textContent = Number(strengthInput.value).toFixed(1) + '×';
|
||||||
|
strengthInput.addEventListener('input', () => {
|
||||||
|
const v = Number(strengthInput.value);
|
||||||
|
if (strengthVal) strengthVal.textContent = v.toFixed(1) + '×';
|
||||||
|
try { localStorage.setItem('ge-look-strength', String(v)); } catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { element: el, open, close, destroy };
|
||||||
|
}
|
||||||
@@ -215,21 +215,19 @@ export function wireAIToolsMisc({
|
|||||||
function addEmptyLayer() {
|
function addEmptyLayer() {
|
||||||
saveState('Add layer');
|
saveState('Add layer');
|
||||||
const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight);
|
const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight);
|
||||||
state.layers.push(layer);
|
// Photoshop inserts the new layer directly above the selected one.
|
||||||
|
const activeIndex = state.layers.findIndex(item => item.id === state.activeLayerId);
|
||||||
|
const insertIndex = activeIndex >= 0 ? activeIndex + 1 : state.layers.length;
|
||||||
|
state.layers.splice(insertIndex, 0, layer);
|
||||||
state.activeLayerId = layer.id;
|
state.activeLayerId = layer.id;
|
||||||
renderLayerPanel();
|
renderLayerPanel();
|
||||||
composite();
|
composite();
|
||||||
}
|
}
|
||||||
const addButton = document.getElementById('ge-add-layer');
|
const buildFxMenu = (anchor) => {
|
||||||
addButton?.addEventListener('click', event => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
document.querySelector('.ge-add-layer-menu')?.remove();
|
document.querySelector('.ge-add-layer-menu')?.remove();
|
||||||
const menu = document.createElement('div');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'ge-add-layer-menu ge-frosted';
|
menu.className = 'ge-add-layer-menu ge-frosted';
|
||||||
menu.innerHTML = `
|
menu.innerHTML = `
|
||||||
<button type="button" data-layer-kind="raster"><span class="ge-add-layer-symbol">+</span><span>Pixel Layer</span></button>
|
|
||||||
<span class="ge-add-layer-divider"></span>
|
|
||||||
<button type="button" data-adjustment-type="levels"><span class="ge-add-layer-symbol">▥</span><span>Levels</span></button>
|
<button type="button" data-adjustment-type="levels"><span class="ge-add-layer-symbol">▥</span><span>Levels</span></button>
|
||||||
<button type="button" data-adjustment-type="curves"><span class="ge-add-layer-symbol">⌁</span><span>Curves</span></button>
|
<button type="button" data-adjustment-type="curves"><span class="ge-add-layer-symbol">⌁</span><span>Curves</span></button>
|
||||||
<button type="button" data-adjustment-type="exposure"><span class="ge-add-layer-symbol">☼</span><span>Exposure</span></button>
|
<button type="button" data-adjustment-type="exposure"><span class="ge-add-layer-symbol">☼</span><span>Exposure</span></button>
|
||||||
@@ -242,9 +240,16 @@ export function wireAIToolsMisc({
|
|||||||
<button type="button" data-adjustment-type="color-balance"><span class="ge-add-layer-symbol">◒</span><span>Color Balance</span></button>
|
<button type="button" data-adjustment-type="color-balance"><span class="ge-add-layer-symbol">◒</span><span>Color Balance</span></button>
|
||||||
<button type="button" data-adjustment-type="selective-color"><span class="ge-add-layer-symbol">◎</span><span>Selective Color</span></button>
|
<button type="button" data-adjustment-type="selective-color"><span class="ge-add-layer-symbol">◎</span><span>Selective Color</span></button>
|
||||||
<button type="button" data-adjustment-type="gradient-map"><span class="ge-add-layer-symbol">▰</span><span>Gradient Map</span></button>
|
<button type="button" data-adjustment-type="gradient-map"><span class="ge-add-layer-symbol">▰</span><span>Gradient Map</span></button>
|
||||||
|
<span class="ge-add-layer-divider"></span>
|
||||||
|
<button type="button" data-adjustment-type="invert"><span class="ge-add-layer-symbol">◑</span><span>Invert</span></button>
|
||||||
|
<button type="button" data-adjustment-type="posterize"><span class="ge-add-layer-symbol">▤</span><span>Posterize</span></button>
|
||||||
|
<button type="button" data-adjustment-type="threshold"><span class="ge-add-layer-symbol">◧</span><span>Threshold</span></button>
|
||||||
|
<button type="button" data-adjustment-type="photo-filter"><span class="ge-add-layer-symbol">◍</span><span>Photo Filter</span></button>
|
||||||
|
<button type="button" data-adjustment-type="channel-mixer"><span class="ge-add-layer-symbol">⇄</span><span>Channel Mixer</span></button>
|
||||||
|
<button type="button" data-adjustment-type="color-lookup"><span class="ge-add-layer-symbol">▦</span><span>Color Lookup</span></button>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
const rect = addButton.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
const menuRect = menu.getBoundingClientRect();
|
const menuRect = menu.getBoundingClientRect();
|
||||||
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`;
|
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`;
|
||||||
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`;
|
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`;
|
||||||
@@ -253,7 +258,7 @@ export function wireAIToolsMisc({
|
|||||||
document.removeEventListener('pointerdown', away, true);
|
document.removeEventListener('pointerdown', away, true);
|
||||||
};
|
};
|
||||||
const away = pointerEvent => {
|
const away = pointerEvent => {
|
||||||
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close();
|
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== anchor) close();
|
||||||
};
|
};
|
||||||
requestAnimationFrame(() => document.addEventListener('pointerdown', away, true));
|
requestAnimationFrame(() => document.addEventListener('pointerdown', away, true));
|
||||||
menu.addEventListener('click', clickEvent => {
|
menu.addEventListener('click', clickEvent => {
|
||||||
@@ -261,10 +266,26 @@ export function wireAIToolsMisc({
|
|||||||
if (!button) return;
|
if (!button) return;
|
||||||
clickEvent.preventDefault();
|
clickEvent.preventDefault();
|
||||||
clickEvent.stopPropagation();
|
clickEvent.stopPropagation();
|
||||||
if (button.dataset.layerKind === 'raster') addEmptyLayer();
|
if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, anchor);
|
||||||
else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton);
|
|
||||||
close();
|
close();
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// "+" adds a plain pixel layer directly.
|
||||||
|
const addButton = document.getElementById('ge-add-layer');
|
||||||
|
addButton?.addEventListener('click', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
document.querySelector('.ge-add-layer-menu')?.remove();
|
||||||
|
addEmptyLayer();
|
||||||
|
});
|
||||||
|
|
||||||
|
// "fx" opens the adjustment-layer chooser.
|
||||||
|
const addFxButton = document.getElementById('ge-add-fx');
|
||||||
|
addFxButton?.addEventListener('click', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
buildFxMenu(addFxButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { addEmptyLayer };
|
return { addEmptyLayer };
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Pure aspect-fitting geometry for the editor. When a whole-image resize is
|
||||||
|
// asked for a target aspect that differs from the current image, content must
|
||||||
|
// be cropped to that aspect (centred on a focus point) and then scaled
|
||||||
|
// UNIFORMLY — never stretched.
|
||||||
|
//
|
||||||
|
// aspectFitCropRect returns the document-space rectangle to crop FIRST so a
|
||||||
|
// subsequent uniform scale-to-{targetW,targetH} preserves the subject aspect
|
||||||
|
// ratio. Returns the full image unchanged when the aspect already matches.
|
||||||
|
|
||||||
|
export function aspectFitCropRect({ width, height, focusX, focusY, targetAspect, tolerance = 0.005 } = {}) {
|
||||||
|
const w = Number(width);
|
||||||
|
const h = Number(height);
|
||||||
|
const a = Number(targetAspect);
|
||||||
|
if (!(w > 0) || !(h > 0) || !(a > 0)) return null;
|
||||||
|
const curAspect = w / h;
|
||||||
|
if (Math.abs(curAspect - a) / a <= tolerance) return { x: 0, y: 0, width: Math.round(w), height: Math.round(h) };
|
||||||
|
const fx = Number.isFinite(Number(focusX)) ? Number(focusX) : w / 2;
|
||||||
|
const fy = Number.isFinite(Number(focusY)) ? Number(focusY) : h / 2;
|
||||||
|
let cw;
|
||||||
|
let ch;
|
||||||
|
if (curAspect > a) {
|
||||||
|
ch = h;
|
||||||
|
cw = Math.max(1, Math.round(ch * a));
|
||||||
|
} else {
|
||||||
|
cw = w;
|
||||||
|
ch = Math.max(1, Math.round(cw / a));
|
||||||
|
}
|
||||||
|
let x = Math.round(fx - cw / 2);
|
||||||
|
let y = Math.round(fy - ch / 2);
|
||||||
|
x = Math.max(0, Math.min(w - cw, x));
|
||||||
|
y = Math.max(0, Math.min(h - ch, y));
|
||||||
|
return { x, y, width: cw, height: ch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { aspectFitCropRect };
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Batch runner — a small, dependency-free orchestrator that applies one action
|
||||||
|
* to every item in a list (open documents), isolating each item so a failure on
|
||||||
|
* one never aborts the batch. Pure async control flow: the caller supplies
|
||||||
|
* `runOne`, so the same loop is unit-testable without an editor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {Array<{id:string,name?:string}>} options.documents
|
||||||
|
* @param {(doc:object, index:number)=>Promise<any>} options.runOne
|
||||||
|
* @param {number} [options.delayMs]
|
||||||
|
* @param {(done:number,total:number,doc:object)=>void} [options.onProgress]
|
||||||
|
* @returns {Promise<Array<{id,name,ok,output?,error?}>>}
|
||||||
|
*/
|
||||||
|
export async function runBatchOverDocuments({ documents, runOne, delayMs = 0, onProgress = null } = {}) {
|
||||||
|
const list = Array.isArray(documents) ? documents.filter(Boolean) : [];
|
||||||
|
const results = [];
|
||||||
|
for (let index = 0; index < list.length; index += 1) {
|
||||||
|
const doc = list[index];
|
||||||
|
try {
|
||||||
|
const output = await runOne(doc, index);
|
||||||
|
results.push({ id: doc.id, name: doc.name, ok: true, output: output === undefined ? null : output });
|
||||||
|
} catch (error) {
|
||||||
|
results.push({ id: doc.id, name: doc.name, ok: false, error: String((error && error.message) || error) });
|
||||||
|
}
|
||||||
|
if (typeof onProgress === 'function') {
|
||||||
|
try { onProgress(index + 1, list.length, doc); } catch { /* progress is best-effort */ }
|
||||||
|
}
|
||||||
|
if (delayMs > 0) await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { runBatchOverDocuments };
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Layer/graphic blend modes.
|
||||||
|
*
|
||||||
|
* Canvas2D can only composite a subset of the Photoshop blend modes natively.
|
||||||
|
* This module exposes that native set plus the "extra" modes PhotoCraft offers
|
||||||
|
* (Dissolve, Linear Burn, Darker Color, Linear Dodge/Add, Lighter Color,
|
||||||
|
* Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide) and a
|
||||||
|
* single `blendDraw` entry point that both paths go through.
|
||||||
|
*
|
||||||
|
* The non-native modes follow the W3C compositing spec (separable + the two
|
||||||
|
* non-separable luminance modes) and operate on ImageData in the overlap of
|
||||||
|
* the source and the destination so rendering stays reasonably cheap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const NATIVE_BLEND_MODES = new Set([
|
||||||
|
'source-over',
|
||||||
|
'multiply',
|
||||||
|
'screen',
|
||||||
|
'overlay',
|
||||||
|
'darken',
|
||||||
|
'lighten',
|
||||||
|
'color-dodge',
|
||||||
|
'color-burn',
|
||||||
|
'hard-light',
|
||||||
|
'soft-light',
|
||||||
|
'difference',
|
||||||
|
'exclusion',
|
||||||
|
'hue',
|
||||||
|
'saturation',
|
||||||
|
'color',
|
||||||
|
'luminosity',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const NATIVE_ALIASES = {
|
||||||
|
'linear-dodge': 'lighter',
|
||||||
|
add: 'lighter',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EXTRA_BLEND_MODES = [
|
||||||
|
{ value: 'linear-dodge', label: 'Linear Dodge (Add)' },
|
||||||
|
{ value: 'linear-burn', label: 'Linear Burn' },
|
||||||
|
{ value: 'darker-color', label: 'Darker Color' },
|
||||||
|
{ value: 'lighter-color', label: 'Lighter Color' },
|
||||||
|
{ value: 'vivid-light', label: 'Vivid Light' },
|
||||||
|
{ value: 'linear-light', label: 'Linear Light' },
|
||||||
|
{ value: 'pin-light', label: 'Pin Light' },
|
||||||
|
{ value: 'hard-mix', label: 'Hard Mix' },
|
||||||
|
{ value: 'subtract', label: 'Subtract' },
|
||||||
|
{ value: 'divide', label: 'Divide' },
|
||||||
|
{ value: 'dissolve', label: 'Dissolve' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NON_SEPARABLE = new Set(['darker-color', 'lighter-color']);
|
||||||
|
|
||||||
|
export function isNativeBlendMode(mode) {
|
||||||
|
if (!mode) return true;
|
||||||
|
return NATIVE_BLEND_MODES.has(mode) || Object.prototype.hasOwnProperty.call(NATIVE_ALIASES, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKnownBlendMode(mode) {
|
||||||
|
if (!mode) return true;
|
||||||
|
return isNativeBlendMode(mode) || EXTRA_BLEND_MODES.some(entry => entry.value === mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value) {
|
||||||
|
return value < 0 ? 0 : value > 1 ? 1 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lum(r, g, b) {
|
||||||
|
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separable per-channel blend functions on normalized [0,1] values.
|
||||||
|
function blendChannel(mode, cb, cs) {
|
||||||
|
switch (mode) {
|
||||||
|
case 'normal':
|
||||||
|
return cs;
|
||||||
|
case 'multiply':
|
||||||
|
return cb * cs;
|
||||||
|
case 'screen':
|
||||||
|
return cb + cs - cb * cs;
|
||||||
|
case 'overlay':
|
||||||
|
return cb <= 0.5 ? 2 * cb * cs : 1 - 2 * (1 - cb) * (1 - cs);
|
||||||
|
case 'darken':
|
||||||
|
return Math.min(cb, cs);
|
||||||
|
case 'lighten':
|
||||||
|
return Math.max(cb, cs);
|
||||||
|
case 'color-dodge':
|
||||||
|
if (cb === 0) return 0;
|
||||||
|
if (cs === 1) return 1;
|
||||||
|
return Math.min(1, cb / (1 - cs));
|
||||||
|
case 'color-burn':
|
||||||
|
if (cb === 1) return 1;
|
||||||
|
if (cs === 0) return 0;
|
||||||
|
return 1 - Math.min(1, (1 - cb) / cs);
|
||||||
|
case 'hard-light':
|
||||||
|
return cs <= 0.5 ? 2 * cs * cb : 1 - 2 * (1 - cs) * (1 - cb);
|
||||||
|
case 'soft-light': {
|
||||||
|
if (cs <= 0.5) return cb - (1 - 2 * cs) * cb * (1 - cb);
|
||||||
|
const d = cb <= 0.25 ? ((16 * cb - 12) * cb + 4) * cb : Math.sqrt(cb);
|
||||||
|
return cb + (2 * cs - 1) * (d - cb);
|
||||||
|
}
|
||||||
|
case 'difference':
|
||||||
|
return Math.abs(cb - cs);
|
||||||
|
case 'exclusion':
|
||||||
|
return cb + cs - 2 * cb * cs;
|
||||||
|
case 'linear-burn':
|
||||||
|
return clamp01(cb + cs - 1);
|
||||||
|
case 'linear-dodge':
|
||||||
|
return clamp01(cb + cs);
|
||||||
|
case 'vivid-light':
|
||||||
|
return cs <= 0.5
|
||||||
|
? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs)))
|
||||||
|
: (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs))));
|
||||||
|
case 'linear-light':
|
||||||
|
return clamp01(cb + 2 * cs - 1);
|
||||||
|
case 'pin-light':
|
||||||
|
return cs <= 0.5 ? Math.min(cb, 2 * cs) : Math.max(cb, 2 * cs - 1);
|
||||||
|
case 'hard-mix': {
|
||||||
|
const vivid = cs <= 0.5
|
||||||
|
? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs)))
|
||||||
|
: (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs))));
|
||||||
|
return vivid < 0.5 ? 0 : 1;
|
||||||
|
}
|
||||||
|
case 'subtract':
|
||||||
|
return clamp01(cb - cs);
|
||||||
|
case 'divide':
|
||||||
|
return cs === 0 ? 1 : clamp01(cb / cs);
|
||||||
|
default:
|
||||||
|
return cs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dissolveRandom(i) {
|
||||||
|
let seed = (i * 2654435761) >>> 0;
|
||||||
|
seed ^= seed >>> 13;
|
||||||
|
seed = (seed * 1274126177) >>> 0;
|
||||||
|
return seed / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSourceData(source) {
|
||||||
|
const sw = source.width || source.naturalWidth || 0;
|
||||||
|
const sh = source.height || source.naturalHeight || 0;
|
||||||
|
if (!sw || !sh) return null;
|
||||||
|
const scratch = document.createElement('canvas');
|
||||||
|
scratch.width = sw;
|
||||||
|
scratch.height = sh;
|
||||||
|
const sctx = scratch.getContext('2d');
|
||||||
|
sctx.drawImage(source, 0, 0, sw, sh);
|
||||||
|
return { data: sctx.getImageData(0, 0, sw, sh).data, sw, sh };
|
||||||
|
}
|
||||||
|
|
||||||
|
function blendCustom(ctx, source, mode, alpha, dx, dy) {
|
||||||
|
if (alpha <= 0) return;
|
||||||
|
const src = ensureSourceData(source);
|
||||||
|
if (!src) return;
|
||||||
|
const tw = ctx.canvas.width;
|
||||||
|
const th = ctx.canvas.height;
|
||||||
|
const sw = src.sw;
|
||||||
|
const sh = src.sh;
|
||||||
|
const sx0 = Math.max(0, -dx);
|
||||||
|
const sy0 = Math.max(0, -dy);
|
||||||
|
const dx0 = Math.max(0, dx);
|
||||||
|
const dy0 = Math.max(0, dy);
|
||||||
|
const w = Math.min(sw - sx0, tw - dx0);
|
||||||
|
const h = Math.min(sh - sy0, th - dy0);
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
|
const dst = ctx.getImageData(dx0, dy0, w, h);
|
||||||
|
const dd = dst.data;
|
||||||
|
const sd = src.data;
|
||||||
|
const nonSeparable = NON_SEPARABLE.has(mode);
|
||||||
|
const dissolve = mode === 'dissolve';
|
||||||
|
|
||||||
|
for (let y = 0; y < h; y += 1) {
|
||||||
|
for (let x = 0; x < w; x += 1) {
|
||||||
|
const si = ((y + sy0) * sw + (x + sx0)) * 4;
|
||||||
|
const di = (y * w + x) * 4;
|
||||||
|
const sa = (sd[si + 3] / 255) * alpha;
|
||||||
|
const ba = dd[di + 3] / 255;
|
||||||
|
|
||||||
|
if (dissolve) {
|
||||||
|
const seed = (y + dy0) * 73856093 ^ (x + dx0) * 19349663;
|
||||||
|
if (dissolveRandom(seed) >= sa) continue;
|
||||||
|
dd[di] = sd[si];
|
||||||
|
dd[di + 1] = sd[si + 1];
|
||||||
|
dd[di + 2] = sd[si + 2];
|
||||||
|
dd[di + 3] = 255;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sa === 0) continue;
|
||||||
|
|
||||||
|
const sr = sd[si] / 255;
|
||||||
|
const sg = sd[si + 1] / 255;
|
||||||
|
const sb = sd[si + 2] / 255;
|
||||||
|
const br = dd[di] / 255;
|
||||||
|
const bg = dd[di + 1] / 255;
|
||||||
|
const bb = dd[di + 2] / 255;
|
||||||
|
|
||||||
|
let mergedR;
|
||||||
|
let mergedG;
|
||||||
|
let mergedB;
|
||||||
|
if (nonSeparable) {
|
||||||
|
const backdropLum = lum(br, bg, bb);
|
||||||
|
const sourceLum = lum(sr, sg, sb);
|
||||||
|
const useSource = mode === 'darker-color' ? sourceLum < backdropLum : sourceLum > backdropLum;
|
||||||
|
mergedR = useSource ? sr : br;
|
||||||
|
mergedG = useSource ? sg : bg;
|
||||||
|
mergedB = useSource ? sb : bb;
|
||||||
|
} else {
|
||||||
|
mergedR = blendChannel(mode, br, sr);
|
||||||
|
mergedG = blendChannel(mode, bg, sg);
|
||||||
|
mergedB = blendChannel(mode, bb, sb);
|
||||||
|
}
|
||||||
|
|
||||||
|
const srEff = (1 - ba) * sr + ba * mergedR;
|
||||||
|
const sgEff = (1 - ba) * sg + ba * mergedG;
|
||||||
|
const sbEff = (1 - ba) * sb + ba * mergedB;
|
||||||
|
|
||||||
|
const ao = sa + ba * (1 - sa);
|
||||||
|
if (ao <= 0) continue;
|
||||||
|
const cr = (sa * srEff + ba * br * (1 - sa)) / ao;
|
||||||
|
const cg = (sa * sgEff + ba * bg * (1 - sa)) / ao;
|
||||||
|
const cbOut = (sa * sbEff + ba * bb * (1 - sa)) / ao;
|
||||||
|
|
||||||
|
dd[di] = Math.round(clamp01(cr) * 255);
|
||||||
|
dd[di + 1] = Math.round(clamp01(cg) * 255);
|
||||||
|
dd[di + 2] = Math.round(clamp01(cbOut) * 255);
|
||||||
|
dd[di + 3] = Math.round(ao * 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.putImageData(dst, dx0, dy0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite `source` onto a 2D context (or canvas) using `mode` at `alpha`.
|
||||||
|
* Native modes use globalCompositeOperation; the rest fall back to pixel math.
|
||||||
|
*/
|
||||||
|
export function blendDraw(target, source, mode, alpha = 1, dx = 0, dy = 0) {
|
||||||
|
if (!target || !source) return;
|
||||||
|
const ctx = typeof target.getContext === 'function' ? target.getContext('2d') : target;
|
||||||
|
const a = Number.isFinite(alpha) ? clamp01(alpha) : 1;
|
||||||
|
const normalized = mode || 'source-over';
|
||||||
|
if (isNativeBlendMode(normalized)) {
|
||||||
|
const prevAlpha = ctx.globalAlpha;
|
||||||
|
const prevOp = ctx.globalCompositeOperation;
|
||||||
|
ctx.globalAlpha = a;
|
||||||
|
ctx.globalCompositeOperation = NATIVE_ALIASES[normalized] || normalized;
|
||||||
|
ctx.drawImage(source, dx, dy);
|
||||||
|
ctx.globalAlpha = prevAlpha;
|
||||||
|
ctx.globalCompositeOperation = prevOp;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blendCustom(ctx, source, normalized, a, dx, dy);
|
||||||
|
}
|
||||||
@@ -24,6 +24,32 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit
|
|||||||
emit(emitted);
|
emit(emitted);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Emit evenly spaced dabs from the last emitted sample toward `target`,
|
||||||
|
// carrying the sub-spacing remainder forward. Shared by drag updates and
|
||||||
|
// the final pointer-up so a stroke ends on the same spacing grid instead
|
||||||
|
// of stamping one detached dab (which reads as a dot where drawing stopped).
|
||||||
|
const emitSpaced = (target) => {
|
||||||
|
if (!emitted) return;
|
||||||
|
const start = { ...emitted };
|
||||||
|
const dx = target.x - start.x;
|
||||||
|
const dy = target.y - start.y;
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
if (!distance) return;
|
||||||
|
const diameter = Math.max(1, Number(getDiameter()) || 1);
|
||||||
|
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
|
||||||
|
let travelled = spacing - remainder;
|
||||||
|
while (travelled <= distance) {
|
||||||
|
const t = travelled / distance;
|
||||||
|
output({
|
||||||
|
x: start.x + dx * t,
|
||||||
|
y: start.y + dy * t,
|
||||||
|
pressure: start.pressure + (target.pressure - start.pressure) * t,
|
||||||
|
});
|
||||||
|
travelled += spacing;
|
||||||
|
}
|
||||||
|
remainder = Math.max(0, distance - (travelled - spacing));
|
||||||
|
};
|
||||||
|
|
||||||
const begin = (sample) => {
|
const begin = (sample) => {
|
||||||
emitted = null;
|
emitted = null;
|
||||||
filtered = { ...sample, pressure: normalisePressure(sample.pressure) };
|
filtered = { ...sample, pressure: normalisePressure(sample.pressure) };
|
||||||
@@ -41,30 +67,14 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit
|
|||||||
y: filtered.y + (raw.y - filtered.y) * response,
|
y: filtered.y + (raw.y - filtered.y) * response,
|
||||||
pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response,
|
pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response,
|
||||||
};
|
};
|
||||||
const start = { ...emitted };
|
emitSpaced(filtered);
|
||||||
const dx = filtered.x - start.x;
|
|
||||||
const dy = filtered.y - start.y;
|
|
||||||
const distance = Math.hypot(dx, dy);
|
|
||||||
if (!distance) return;
|
|
||||||
const diameter = Math.max(1, Number(getDiameter()) || 1);
|
|
||||||
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
|
|
||||||
let travelled = spacing - remainder;
|
|
||||||
while (travelled <= distance) {
|
|
||||||
const t = travelled / distance;
|
|
||||||
output({
|
|
||||||
x: start.x + dx * t,
|
|
||||||
y: start.y + dy * t,
|
|
||||||
pressure: start.pressure + (filtered.pressure - start.pressure) * t,
|
|
||||||
});
|
|
||||||
travelled += spacing;
|
|
||||||
}
|
|
||||||
remainder = Math.max(0, distance - (travelled - spacing));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const end = (sample) => {
|
const end = (sample) => {
|
||||||
|
// Reach the true pointer-up point along the spacing grid rather than with
|
||||||
|
// a single far dab, so the stroke does not terminate in a detached dot.
|
||||||
if (sample && emitted) {
|
if (sample && emitted) {
|
||||||
const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) };
|
emitSpaced({ ...sample, pressure: normalisePressure(sample.pressure) });
|
||||||
if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample);
|
|
||||||
}
|
}
|
||||||
emitted = null;
|
emitted = null;
|
||||||
filtered = null;
|
filtered = null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const BUILT_INS = [
|
|||||||
{ id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' },
|
{ id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' },
|
||||||
{ id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' },
|
{ id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' },
|
||||||
{ id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' },
|
{ id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' },
|
||||||
|
{ id: 'pixel-1px', name: 'Pixel', size: 1, opacity: 100, flow: 100, softness: 0, spacing: 1, smoothing: 0, blendMode: 'source-over', pixel: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function loadBrushPresets(storage = globalThis.localStorage) {
|
export function loadBrushPresets(storage = globalThis.localStorage) {
|
||||||
@@ -31,6 +32,7 @@ export function captureBrushPreset(state, name) {
|
|||||||
spacing: state.brushSpacing,
|
spacing: state.brushSpacing,
|
||||||
smoothing: state.brushSmoothing,
|
smoothing: state.brushSmoothing,
|
||||||
blendMode: state.brushBlendMode,
|
blendMode: state.brushBlendMode,
|
||||||
|
pixel: !!state.brushPixel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,4 +45,5 @@ export function applyBrushPreset(state, preset) {
|
|||||||
state.brushSpacing = preset.spacing;
|
state.brushSpacing = preset.spacing;
|
||||||
state.brushSmoothing = preset.smoothing;
|
state.brushSmoothing = preset.smoothing;
|
||||||
state.brushBlendMode = preset.blendMode || 'source-over';
|
state.brushBlendMode = preset.blendMode || 'source-over';
|
||||||
|
state.brushPixel = !!preset.pixel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/** Command palette (Ctrl/Cmd+K): fuzzy list of tools, panels and actions. */
|
||||||
|
|
||||||
|
export function createCommandPalette({ commands = [], onRun = null } = {}) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'ge-command-palette';
|
||||||
|
overlay.hidden = true;
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-label', 'Command palette');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="ge-command-panel">
|
||||||
|
<input class="ge-command-input" type="text" placeholder="Type a command..." aria-label="Command" autocomplete="off" />
|
||||||
|
<div class="ge-command-list" role="listbox"></div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const input = overlay.querySelector('.ge-command-input');
|
||||||
|
const list = overlay.querySelector('.ge-command-list');
|
||||||
|
let filtered = [];
|
||||||
|
let activeIndex = 0;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const query = input.value.trim().toLowerCase();
|
||||||
|
filtered = commands.filter(cmd => !query || String(cmd.label).toLowerCase().includes(query));
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!filtered.length) {
|
||||||
|
const empty = document.createElement('p');
|
||||||
|
empty.className = 'ge-command-empty';
|
||||||
|
empty.textContent = 'No matching commands';
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeIndex >= filtered.length) activeIndex = 0;
|
||||||
|
filtered.forEach((cmd, index) => {
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.type = 'button';
|
||||||
|
row.className = `ge-command-item${index === activeIndex ? ' active' : ''}`;
|
||||||
|
row.setAttribute('role', 'option');
|
||||||
|
row.setAttribute('aria-selected', index === activeIndex ? 'true' : 'false');
|
||||||
|
row.textContent = cmd.label;
|
||||||
|
row.addEventListener('click', () => run(cmd));
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd) {
|
||||||
|
close();
|
||||||
|
if (typeof cmd.run === 'function') cmd.run();
|
||||||
|
else if (typeof onRun === 'function') onRun(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
overlay.hidden = false;
|
||||||
|
input.value = '';
|
||||||
|
activeIndex = 0;
|
||||||
|
render();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
overlay.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', () => { activeIndex = 0; render(); });
|
||||||
|
input.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') { event.preventDefault(); close(); }
|
||||||
|
else if (event.key === 'ArrowDown') { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); }
|
||||||
|
else if (event.key === 'ArrowUp') { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); }
|
||||||
|
else if (event.key === 'Enter') { event.preventDefault(); if (filtered[activeIndex]) run(filtered[activeIndex]); }
|
||||||
|
});
|
||||||
|
overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); });
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: overlay,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
toggle: () => (overlay.hidden ? open() : close()),
|
||||||
|
isOpen: () => !overlay.hidden,
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+429
-178
@@ -6,10 +6,10 @@
|
|||||||
* sections are all toggled `display:none` here; the tool-switch handler
|
* sections are all toggled `display:none` here; the tool-switch handler
|
||||||
* in galleryEditor.js shows the section matching the active tool.
|
* in galleryEditor.js shows the section matching the active tool.
|
||||||
*
|
*
|
||||||
* @param {{ color: string, brushSize: number, wandTolerance: number }} ctx
|
* @param {{ color: string, brushSize: number, wandTolerance: number, bucketTolerance: number, cropAspectPreset: string, eyedropperSampleSize: number }} ctx
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function controlsHTML({ color, brushSize, wandTolerance }) {
|
export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance = 32, cropAspectPreset = 'free', eyedropperSampleSize = 1 }) {
|
||||||
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
|
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
|
||||||
return `
|
return `
|
||||||
<div id="ge-pen-section" style="display:none;">
|
<div id="ge-pen-section" style="display:none;">
|
||||||
@@ -19,6 +19,28 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-pen-cancel" title="Cancel path (Escape)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m18 6-12 12M6 6l12 12"/></svg> Cancel</button>
|
<button type="button" class="ge-btn ge-btn-sm" id="ge-pen-cancel" title="Cancel path (Escape)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m18 6-12 12M6 6l12 12"/></svg> Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="ge-liquify-section" style="display:none;">
|
||||||
|
<div class="ge-section-title">Liquify</div>
|
||||||
|
<div class="ge-control-row">
|
||||||
|
<label for="ge-liquify-mode">Mode</label>
|
||||||
|
<select id="ge-liquify-mode" class="ge-ctl-select">
|
||||||
|
<option value="push">Push</option>
|
||||||
|
<option value="twirl">Twirl</option>
|
||||||
|
<option value="pucker">Pucker</option>
|
||||||
|
<option value="bloat">Bloat</option>
|
||||||
|
<option value="smooth">Smooth</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<label><span id="ge-liquify-size-label">120px</span></label>
|
||||||
|
<input type="range" id="ge-liquify-size" min="10" max="600" value="120" />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<label>Strength <span id="ge-liquify-strength-label">50%</span></label>
|
||||||
|
<input type="range" id="ge-liquify-strength" min="1" max="100" value="50" />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-liquify-hint">Drag on the canvas to warp pixels. One undo step per stroke.</div>
|
||||||
|
</div>
|
||||||
<div class="ge-layer-geometry-section" id="ge-layer-geometry-section">
|
<div class="ge-layer-geometry-section" id="ge-layer-geometry-section">
|
||||||
<div class="ge-section-title"><span>Position</span><span id="ge-layer-geometry-name"></span></div>
|
<div class="ge-section-title"><span>Position</span><span id="ge-layer-geometry-name"></span></div>
|
||||||
<div class="ge-layer-geometry-grid">
|
<div class="ge-layer-geometry-grid">
|
||||||
@@ -33,31 +55,105 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
<label>Color</label>
|
<label>Color</label>
|
||||||
<input type="color" class="ge-color-picker" value="${color}" />
|
<input type="color" class="ge-color-picker" value="${color}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row">
|
<div class="ge-control-row ge-brush-size-row">
|
||||||
<label>Size <span class="ge-size-label">${brushSize}px</span></label>
|
<label><span class="ge-size-label">${brushSize}px</span></label>
|
||||||
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
|
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
|
||||||
|
<span class="ge-brush-more-wrap">
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-brush-more-btn" id="ge-brush-more" aria-haspopup="true" aria-expanded="false" title="More brush options (spacing, smoothing, blend, opacity, pen pressure)" aria-label="More brush options">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="ge-brush-more-menu" id="ge-brush-more-menu" hidden>
|
||||||
|
<div class="ge-section-title">Stroke</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-brush-preview-spacing" aria-hidden="true"></span>
|
||||||
|
<label>Spacing <span id="ge-brush-spacing-label">15%</span></label>
|
||||||
|
<input type="range" id="ge-brush-spacing" min="1" max="100" value="15" />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-brush-preview-smoothing" aria-hidden="true"></span>
|
||||||
|
<label>Smoothing <span id="ge-brush-smoothing-label">25%</span></label>
|
||||||
|
<input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row">
|
||||||
|
<label for="ge-brush-blend">Blend</label>
|
||||||
|
<select id="ge-brush-blend">
|
||||||
|
<option value="source-over">Normal</option><option value="multiply">Multiply</option>
|
||||||
|
<option value="screen">Screen</option><option value="overlay">Overlay</option>
|
||||||
|
<option value="soft-light">Soft Light</option><option value="color">Color</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="ge-brush-stroke-group" data-stroke-tool="brush">
|
||||||
|
<div class="ge-section-title" id="ge-stroke-title-brush">Brush</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row" id="ge-brush-opacity-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-brush-preview-opacity" aria-hidden="true"></span>
|
||||||
|
<label>Opacity <span id="ge-brush-opacity-label">100%</span></label>
|
||||||
|
<input type="range" id="ge-brush-opacity" min="10" max="100" value="100" />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row" id="ge-brush-flow-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-brush-preview-flow" aria-hidden="true"></span>
|
||||||
|
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
|
||||||
|
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
|
||||||
|
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
|
||||||
|
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ge-brush-stroke-group" data-stroke-tool="eraser">
|
||||||
|
<div class="ge-section-title">Eraser</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-eraser-preview-opacity" aria-hidden="true"></span>
|
||||||
|
<label>Opacity <span id="ge-eraser-opacity-label">100%</span></label>
|
||||||
|
<input type="range" id="ge-eraser-opacity" min="10" max="100" value="100" />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-eraser-preview-flow" aria-hidden="true"></span>
|
||||||
|
<label>Flow <span id="ge-eraser-flow-label">100%</span></label>
|
||||||
|
<input type="range" id="ge-eraser-flow" min="5" max="100" value="100" />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-eraser-preview-softness" aria-hidden="true"></span>
|
||||||
|
<label>Softness <span id="ge-eraser-softness-label">100%</span></label>
|
||||||
|
<input type="range" id="ge-eraser-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha so the eraser fades out at the perimeter." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ge-brush-stroke-group" data-stroke-tool="clone">
|
||||||
|
<div class="ge-section-title" id="ge-stroke-title-clone">Clone</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-clone-preview-opacity" aria-hidden="true"></span>
|
||||||
|
<label>Opacity <span id="ge-clone-opacity-label">100%</span></label>
|
||||||
|
<input type="range" id="ge-clone-opacity" min="10" max="100" value="100" />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-clone-preview-flow" aria-hidden="true"></span>
|
||||||
|
<label>Flow <span id="ge-clone-flow-label">100%</span></label>
|
||||||
|
<input type="range" id="ge-clone-flow" min="5" max="100" value="100" />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-clone-preview-softness" aria-hidden="true"></span>
|
||||||
|
<label>Softness <span id="ge-clone-softness-label">100%</span></label>
|
||||||
|
<input type="range" id="ge-clone-softness" min="0" max="300" value="100" title="Soft brush edge — blurs each stamp for a feathered fade." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ge-brush-stroke-group" data-stroke-tool="smudge">
|
||||||
|
<div class="ge-section-title">Smudge</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row" id="ge-smudge-strength-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-smudge-preview-strength" aria-hidden="true"></span>
|
||||||
|
<label>Strength <span id="ge-smudge-strength-label">65%</span></label>
|
||||||
|
<input type="range" id="ge-smudge-strength" min="5" max="100" value="65" title="How strongly Smudge carries sampled pixels into the stroke." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-brush-pressure-row">
|
||||||
|
<span class="ge-pressure-option" title="Pen pressure changes flow"><span>Flow</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-flow" checked /><span class="toggle-slider"></span></label></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-section-title">Stroke</div>
|
<div class="ge-control-row ge-eraser-row" id="ge-dodgeburn-intensity-row" style="display:none;">
|
||||||
<div class="ge-control-row ge-eraser-row">
|
<span class="ge-eraser-preview" id="ge-dodgeburn-preview" aria-hidden="true"></span>
|
||||||
<label>Spacing <span id="ge-brush-spacing-label">15%</span></label>
|
<label title="Intensity">Int <span id="ge-dodgeburn-intensity-label">25%</span></label>
|
||||||
<input type="range" id="ge-brush-spacing" min="1" max="100" value="15" />
|
<input type="range" id="ge-dodgeburn-intensity" min="0" max="100" value="25" title="How strongly Dodge lightens or Burn darkens each dab." />
|
||||||
</div>
|
|
||||||
<div class="ge-control-row ge-eraser-row">
|
|
||||||
<label>Smoothing <span id="ge-brush-smoothing-label">25%</span></label>
|
|
||||||
<input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" />
|
|
||||||
</div>
|
|
||||||
<div class="ge-control-row">
|
|
||||||
<label for="ge-brush-blend">Blend</label>
|
|
||||||
<select id="ge-brush-blend">
|
|
||||||
<option value="source-over">Normal</option><option value="multiply">Multiply</option>
|
|
||||||
<option value="screen">Screen</option><option value="overlay">Overlay</option>
|
|
||||||
<option value="soft-light">Soft Light</option><option value="color">Color</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="ge-control-row ge-brush-pressure-row">
|
|
||||||
<span class="ge-pressure-option" title="Pen pressure changes brush size"><span>Size</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-size" checked /><span class="toggle-slider"></span></label></span>
|
|
||||||
<span class="ge-pressure-option" title="Pen pressure changes opacity"><span>Opacity</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-opacity" /><span class="toggle-slider"></span></label></span>
|
|
||||||
<span class="ge-pressure-option" title="Pen pressure changes flow"><span>Flow</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-flow" checked /><span class="toggle-slider"></span></label></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row ge-brush-preset-row">
|
<div class="ge-control-row ge-brush-preset-row">
|
||||||
<select id="ge-brush-preset" aria-label="Brush preset"></select>
|
<select id="ge-brush-preset" aria-label="Brush preset"></select>
|
||||||
@@ -96,19 +192,47 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row"><label for="ge-eyedropper-sample">Sample</label><select id="ge-eyedropper-sample"><option value="composite">All layers</option><option value="layer">Active layer</option></select></div>
|
<div class="ge-control-row"><label for="ge-eyedropper-sample">Sample</label><select id="ge-eyedropper-sample"><option value="composite">All layers</option><option value="layer">Active layer</option></select></div>
|
||||||
|
<div class="ge-control-row"><label for="ge-eyedropper-sample-size">Sample Size</label><select id="ge-eyedropper-sample-size" title="Point samples one pixel; the averages blur the reading over an N-by-N block.">${[1, 3, 5, 11, 31, 51].map((n) => `<option value="${n}"${Number(eyedropperSampleSize) === n ? ' selected' : ''}>${n === 1 ? 'Point Sample' : `${n} by ${n} Average`}</option>`).join('')}</select></div>
|
||||||
|
</div>
|
||||||
|
<div class="ge-bucket-section" id="ge-bucket-section" style="display:none;">
|
||||||
|
<div class="ge-section-title">Paint Bucket</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<label for="ge-bucket-tolerance">Tolerance <span id="ge-bucket-tolerance-label">${bucketTolerance}</span></label>
|
||||||
|
<input type="range" id="ge-bucket-tolerance" min="0" max="255" value="${bucketTolerance}" title="How different a neighbouring pixel can be and still be filled (0-255)." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ge-crop-section" id="ge-crop-section" style="display:none;">
|
||||||
|
<div class="ge-section-title">Crop</div>
|
||||||
|
<div class="ge-control-row ge-crop-preset-row" id="ge-crop-presets" role="group" aria-label="Crop aspect ratio">
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === 'free' ? ' active' : ''}" data-crop-preset="free" title="Crop freely (no aspect lock)">Free</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === 'original' ? ' active' : ''}" data-crop-preset="original" title="Keep the original image aspect ratio">Original</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '1:1' ? ' active' : ''}" data-crop-preset="1:1" title="Square (1:1)">1:1</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '4:5' ? ' active' : ''}" data-crop-preset="4:5" title="Portrait (4:5)">4:5</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-crop-preset${cropAspectPreset === '16:9' ? ' active' : ''}" data-crop-preset="16:9" title="Widescreen (16:9)">16:9</button>
|
||||||
|
<label class="ge-crop-custom" title="Type any ratio, e.g. 3:2 or 21:9">
|
||||||
|
<input type="text" id="ge-crop-custom-ratio" inputmode="numeric" placeholder="W:H" aria-label="Custom crop ratio" spellcheck="false" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="ge-section-hint">Pick a ratio, then drag on the canvas. Press Enter to apply.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-text-section" id="ge-text-section" style="display:none;">
|
<div class="ge-text-section" id="ge-text-section" style="display:none;">
|
||||||
<textarea id="ge-text-content" rows="3" placeholder="Type text..." aria-label="Text content"></textarea>
|
<textarea id="ge-text-content" rows="2" placeholder="Type text..." aria-label="Text content"></textarea>
|
||||||
<div class="ge-text-grid">
|
<div class="ge-text-grid">
|
||||||
<label class="ge-text-field ge-text-font-field"><span>Font</span>
|
<label class="ge-text-field ge-text-font-field"><span>Font</span>
|
||||||
<select id="ge-text-font">
|
<select id="ge-text-font">
|
||||||
<option value="Arial">Arial</option>
|
<option value="Inter">Inter</option>
|
||||||
<option value="Verdana">Verdana</option>
|
<option value="Roboto">Roboto</option>
|
||||||
|
<option value="Playfair Display">Playfair Display</option>
|
||||||
|
<option value="Lobster">Lobster</option>
|
||||||
|
<option value="Bebas Neue">Bebas Neue</option>
|
||||||
|
<option value="Roboto Mono">Roboto Mono</option>
|
||||||
|
<option value="Space Grotesk">Space Grotesk</option>
|
||||||
|
<option value="Fraunces">Fraunces</option>
|
||||||
|
<option value="Syne">Syne</option>
|
||||||
|
<option value="Bungee">Bungee</option>
|
||||||
|
<option value="Instrument Serif">Instrument Serif</option>
|
||||||
<option value="Georgia">Georgia</option>
|
<option value="Georgia">Georgia</option>
|
||||||
<option value="Times New Roman">Times New Roman</option>
|
|
||||||
<option value="Courier New">Courier New</option>
|
<option value="Courier New">Courier New</option>
|
||||||
<option value="Impact">Impact</option>
|
|
||||||
<option value="system-ui">System</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="ge-text-field ge-text-size-field"><span>Size</span>
|
<label class="ge-text-field ge-text-size-field"><span>Size</span>
|
||||||
@@ -124,6 +248,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
<button type="button" class="ge-text-toggle" data-text-align="center" title="Align center" aria-pressed="false">↔</button>
|
<button type="button" class="ge-text-toggle" data-text-align="center" title="Align center" aria-pressed="false">↔</button>
|
||||||
<button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">⇥</button>
|
<button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">⇥</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ge-opt-more" id="ge-text-more" hidden>
|
||||||
<div class="ge-text-grid">
|
<div class="ge-text-grid">
|
||||||
<label class="ge-text-field"><span>Line</span>
|
<label class="ge-text-field"><span>Line</span>
|
||||||
<input id="ge-text-line-height" type="number" min="0.5" max="5" step="0.1" value="1.2" />
|
<input id="ge-text-line-height" type="number" min="0.5" max="5" step="0.1" value="1.2" />
|
||||||
@@ -150,6 +275,38 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
<input type="color" class="ge-color-picker" id="ge-text-stroke-color" value="#000000" title="Stroke color" aria-label="Stroke color" />
|
<input type="color" class="ge-color-picker" id="ge-text-stroke-color" value="#000000" title="Stroke color" aria-label="Stroke color" />
|
||||||
</div>
|
</div>
|
||||||
<label class="ge-text-auto-width-option"><span>Auto width</span><span class="toggle-switch"><input type="checkbox" id="ge-text-auto-width" /><span class="toggle-slider"></span></span></label>
|
<label class="ge-text-auto-width-option"><span>Auto width</span><span class="toggle-switch"><input type="checkbox" id="ge-text-auto-width" /><span class="toggle-slider"></span></span></label>
|
||||||
|
<div class="ge-text-grid">
|
||||||
|
<label class="ge-text-field"><span>Writing</span>
|
||||||
|
<select id="ge-text-direction"><option value="horizontal">Horizontal</option><option value="vertical">Vertical</option></select>
|
||||||
|
</label>
|
||||||
|
<label class="ge-text-field"><span>Warp</span>
|
||||||
|
<select id="ge-text-warp">
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="arc">Arc</option>
|
||||||
|
<option value="arch">Arch</option>
|
||||||
|
<option value="bulge">Bulge</option>
|
||||||
|
<option value="flag">Flag</option>
|
||||||
|
<option value="wave">Wave</option>
|
||||||
|
<option value="rise">Rise</option>
|
||||||
|
<option value="fisheye">Fisheye</option>
|
||||||
|
<option value="inflate">Inflate</option>
|
||||||
|
<option value="squeeze">Squeeze</option>
|
||||||
|
<option value="twist">Twist</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="ge-text-grid">
|
||||||
|
<label class="ge-text-field"><span>Warp amt</span>
|
||||||
|
<input id="ge-text-warp-amount" type="range" min="-100" max="100" step="1" value="0" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="ge-text-grid ge-text-opentype-row">
|
||||||
|
<label class="ge-text-field ge-text-inline-check"><span>Small caps</span><input type="checkbox" id="ge-text-small-caps" /></label>
|
||||||
|
<label class="ge-text-field ge-text-inline-check"><span>Kerning</span><input type="checkbox" id="ge-text-kerning" checked /></label>
|
||||||
|
<label class="ge-text-field ge-text-inline-check"><span>Ligatures</span><input type="checkbox" id="ge-text-ligatures" checked /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-more-toggle" id="ge-text-more-toggle" aria-expanded="false" aria-controls="ge-text-more">More</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-text-rasterize">Rasterize</button>
|
<button type="button" class="ge-btn ge-btn-sm" id="ge-text-rasterize">Rasterize</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-shape-section" id="ge-shape-section" style="display:none;">
|
<div class="ge-shape-section" id="ge-shape-section" style="display:none;">
|
||||||
@@ -164,6 +321,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-shape-fill" value="${color}" /></label>
|
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-shape-fill" value="${color}" /></label>
|
||||||
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-shape-stroke" value="#111111" /></label>
|
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-shape-stroke" value="#111111" /></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ge-opt-more" id="ge-shape-more" hidden>
|
||||||
<div class="ge-text-grid">
|
<div class="ge-text-grid">
|
||||||
<label class="ge-text-field"><span>Fill type</span>
|
<label class="ge-text-field"><span>Fill type</span>
|
||||||
<select id="ge-shape-fill-type"><option value="solid">Solid</option><option value="linear-gradient">Linear gradient</option></select>
|
<select id="ge-shape-fill-type"><option value="solid">Solid</option><option value="linear-gradient">Linear gradient</option></select>
|
||||||
@@ -184,8 +342,26 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
<label class="ge-text-field"><span>Radius</span><input id="ge-shape-radius" type="number" min="0" max="5000" step="1" value="0" /></label>
|
<label class="ge-text-field"><span>Radius</span><input id="ge-shape-radius" type="number" min="0" max="5000" step="1" value="0" /></label>
|
||||||
</div>
|
</div>
|
||||||
<label class="ge-text-field ge-shape-sides-field" hidden><span>Sides</span><input id="ge-shape-sides" type="number" min="3" max="24" step="1" value="5" /></label>
|
<label class="ge-text-field ge-shape-sides-field" hidden><span>Sides</span><input id="ge-shape-sides" type="number" min="3" max="24" step="1" value="5" /></label>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-more-toggle" id="ge-shape-more-toggle" aria-expanded="false" aria-controls="ge-shape-more">More</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button>
|
<button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ge-path-section" id="ge-path-section" style="display:none;">
|
||||||
|
<div class="ge-section-title">Path</div>
|
||||||
|
<p class="ge-section-hint">Click to add anchors; double-click or press Enter to finish.</p>
|
||||||
|
<div class="ge-text-grid">
|
||||||
|
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-path-fill" value="${color}" /></label>
|
||||||
|
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-path-stroke" value="#111111" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="ge-text-grid">
|
||||||
|
<label class="ge-text-field"><span>Width</span><input id="ge-path-stroke-width" type="number" min="0" max="500" step="1" value="2" /></label>
|
||||||
|
<label class="ge-text-field ge-path-closed-field"><span>Close path</span><input id="ge-path-closed" type="checkbox" checked /></label>
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row" style="display:flex;gap:4px;">
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm" id="ge-path-finish">Finish</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm" id="ge-path-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="ge-marquee-section" id="ge-marquee-section" style="display:none;">
|
<div class="ge-marquee-section" id="ge-marquee-section" style="display:none;">
|
||||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;">
|
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;">
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn active" data-marquee-shape="rectangle" title="Rectangular marquee" aria-pressed="true">
|
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn active" data-marquee-shape="rectangle" title="Rectangular marquee" aria-pressed="true">
|
||||||
@@ -198,14 +374,13 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-marquee-constraint-row">
|
<div class="ge-marquee-constraint-row">
|
||||||
<label for="ge-marquee-constraint">Style</label>
|
<label for="ge-marquee-constraint">Style</label>
|
||||||
<select id="ge-marquee-constraint" title="Marquee sizing style">
|
<select id="ge-marquee-constraint" title="Marquee sizing style">
|
||||||
<option value="free">Free</option>
|
<option value="free">Free</option>
|
||||||
<option value="ratio">Fixed ratio</option>
|
<option value="ratio">Fixed ratio</option>
|
||||||
<option value="size">Fixed size</option>
|
|
||||||
</select>
|
</select>
|
||||||
<div class="ge-marquee-dimensions" hidden>
|
<div class="ge-marquee-dimensions" hidden>
|
||||||
<label>W <input type="number" id="ge-marquee-width" min="1" step="1" value="1" inputmode="decimal" /></label>
|
<label>W <input type="number" id="ge-marquee-width" min="1" step="1" value="1" inputmode="decimal" /></label>
|
||||||
@@ -215,16 +390,18 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-marquee-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-marquee-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-clear" title="Clear selection">Clear</button>
|
<button class="ge-btn ge-btn-sm" id="ge-marquee-clear" title="Clear selection" aria-label="Clear selection">
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection">Invert</button>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels">Erase</button>
|
</button>
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-copy" title="Copy selection to a new layer">Copy Layer</button>
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>Invert</button>
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-mask" title="Add selection to mask">To Mask</button>
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>Erase</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-copy" title="Copy selection to a new layer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy Layer</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-mask" title="Add selection to mask"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>To Mask</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
|
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
|
||||||
@@ -232,50 +409,62 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-feather" style="display:none;">
|
<span class="ge-lasso-more-wrap">
|
||||||
<span class="ge-eraser-preview" id="ge-lasso-feather-preview" aria-hidden="true"></span>
|
<button type="button" class="ge-btn ge-btn-sm ge-lasso-more-btn" id="ge-lasso-more" aria-haspopup="true" aria-expanded="false" aria-label="More lasso options (feather, edge stroke, magnetic)" title="More lasso options (feather, edge stroke, magnetic)">
|
||||||
<label>Feather <span id="ge-lasso-feather-label">0px</span></label>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
<input type="range" id="ge-lasso-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
|
</button>
|
||||||
|
<div class="ge-lasso-more-menu" id="ge-lasso-more-menu" hidden>
|
||||||
|
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-feather" style="display:none;">
|
||||||
|
<span class="ge-eraser-preview" id="ge-lasso-feather-preview" aria-hidden="true"></span>
|
||||||
|
<label>Feather <span id="ge-lasso-feather-label">0px</span></label>
|
||||||
|
<input type="range" id="ge-lasso-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-grow" style="display:none;">
|
||||||
|
<span class="ge-eraser-preview" id="ge-lasso-grow-preview" aria-hidden="true"></span>
|
||||||
|
<label>Edge stroke <span id="ge-lasso-grow-label">0px</span></label>
|
||||||
|
<input type="range" id="ge-lasso-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row">
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm" id="ge-lasso-magnetic" aria-pressed="false" title="Snap the outline to image edges as you drag.">Magnetic</button>
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-width-row" style="display:none;">
|
||||||
|
<label>Width <span id="ge-lasso-mag-width-label">12px</span></label>
|
||||||
|
<input type="range" id="ge-lasso-mag-width" min="2" max="60" value="12" title="Search radius for the strongest nearby edge." />
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row" id="ge-lasso-mag-contrast-row" style="display:none;">
|
||||||
|
<label>Contrast <span id="ge-lasso-mag-contrast-label">20%</span></label>
|
||||||
|
<input type="range" id="ge-lasso-mag-contrast" min="0" max="100" value="20" title="Minimum edge strength to snap to." />
|
||||||
|
</div>
|
||||||
|
<p class="ge-lasso-tip">Draw a freehand selection. Esc to cancel.</p>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||||
|
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-lasso-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm" id="ge-lasso-clear" title="Clear selection" aria-label="Clear selection">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+Shift+I)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>Invert</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-delete" title="Erase selected pixels"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>Erase</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-copy" title="Copy selection to a new layer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy Layer</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-mask" title="Add selection to mask"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>To Mask</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-grow" style="display:none;">
|
|
||||||
<span class="ge-eraser-preview" id="ge-lasso-grow-preview" aria-hidden="true"></span>
|
|
||||||
<label>Edge stroke <span id="ge-lasso-grow-label">0px</span></label>
|
|
||||||
<input type="range" id="ge-lasso-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
|
|
||||||
</div>
|
|
||||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
|
||||||
Invert
|
|
||||||
</button>
|
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-delete" title="Delete selected pixels from the layer">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-copy" title="Copy selection to new layer">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
||||||
Copy Layer
|
|
||||||
</button>
|
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-mask" title="Convert selection to inpaint mask">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
|
|
||||||
To Mask
|
|
||||||
</button>
|
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
|
||||||
</div>
|
|
||||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a freehand selection. Esc to cancel.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-wand-section" id="ge-wand-section" style="display:none;">
|
<div class="ge-wand-section" id="ge-wand-section" style="display:none;">
|
||||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next click combines with the current selection. Shift / Alt held during a click override this for one click.">
|
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next click combines with the current selection. Shift / Alt held during a click override this for one click.">
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection on each click">New</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection on each click">New</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection (Shift)">+ Add</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection (Shift)">+</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection (Alt)">− Subtract</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection (Alt)">−</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-live-btn" id="ge-wand-live" title="Retune selection while dragging tolerance" aria-pressed="false">Live</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row ge-eraser-row">
|
<div class="ge-control-row ge-eraser-row">
|
||||||
<span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span>
|
<span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span>
|
||||||
<label>Tolerance <span id="ge-wand-tol-label">${wandTolerance}</span></label>
|
<label>Tolerance <span id="ge-wand-tol-label">${wandTolerance}</span></label>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-live-btn" id="ge-wand-live" title="Retune selection while dragging tolerance" aria-pressed="false">Live</button>
|
|
||||||
<input type="range" id="ge-wand-tolerance" min="0" max="100" value="${wandTolerance}" />
|
<input type="range" id="ge-wand-tolerance" min="0" max="100" value="${wandTolerance}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-wand-refine-feather" style="display:none;">
|
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-wand-refine-feather" style="display:none;">
|
||||||
@@ -288,41 +477,38 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
<label>Edge stroke <span id="ge-wand-grow-label">0px</span></label>
|
<label>Edge stroke <span id="ge-wand-grow-label">0px</span></label>
|
||||||
<input type="range" id="ge-wand-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
|
<input type="range" id="ge-wand-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-wand-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-wand-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-clear" title="Clear the selection">
|
<button class="ge-btn ge-btn-sm" id="ge-wand-clear" title="Clear selection" aria-label="Clear selection">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||||
Clear
|
|
||||||
</button>
|
</button>
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||||
Invert
|
Invert
|
||||||
</button>
|
</button>
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-delete" title="Delete selected pixels from the layer">
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-delete" title="Erase selected pixels">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
|
||||||
Erase
|
Erase
|
||||||
</button>
|
</button>
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-copy" title="Copy selection to a new layer">
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-copy" title="Copy selection to a new layer">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
Copy Layer
|
Copy Layer
|
||||||
</button>
|
</button>
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-mask" title="Add selection to the inpaint mask">
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-mask" title="Add selection to mask">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
|
||||||
To Mask
|
To Mask
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click a region to select similar pixels. Shift+click to add, Alt+click to subtract. Esc to clear.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-sam-section" id="ge-sam-section" style="display:none;">
|
<div class="ge-sam-section" id="ge-sam-section" style="display:none;">
|
||||||
<div class="ge-section-title ge-section-title-with-help"><span>SAM</span><span class="ge-section-help" tabindex="0" role="img" aria-label="SAM selection help" title="Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask.">?</span></div>
|
|
||||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next SAM selection combines with the current selection. Shift / Alt held during a click override this for one click.">
|
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next SAM selection combines with the current selection. Shift / Alt held during a click override this for one click.">
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
|
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
|
||||||
<input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" />
|
<input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" />
|
||||||
@@ -331,21 +517,120 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
Find
|
Find
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
<span class="ge-wand-more-wrap">
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-sam-more" aria-haspopup="true" aria-expanded="false" aria-label="More SAM options (help)" title="More SAM options (help)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
|
||||||
|
<div class="ge-wand-more-menu" id="ge-sam-more-menu" hidden>
|
||||||
|
<p class="ge-wand-tip">Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask. Shift adds, Alt subtracts, Esc clears.</p>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-sam-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-sam-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-clear" title="Clear the selection">
|
<button class="ge-btn ge-btn-sm" id="ge-sam-clear" title="Clear selection" aria-label="Clear selection">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||||
Clear
|
|
||||||
</button>
|
</button>
|
||||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to the inpaint mask">
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||||
|
Invert
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-delete" title="Erase selected pixels">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
|
||||||
|
Erase
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-copy" title="Copy selection to a new layer">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
Copy Layer
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to mask">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
|
||||||
To Mask
|
To Mask
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ge-quickselect-section" id="ge-quickselect-section" style="display:none;">
|
||||||
|
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the brush combines with the current selection. Shift / Alt held during a stroke override this.">
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-quickselect-size-preview" aria-hidden="true"></span>
|
||||||
|
<label>Brush <span id="ge-quickselect-size-label">24</span></label>
|
||||||
|
<input type="range" class="ge-quickselect-size" id="ge-quickselect-size" min="4" max="200" value="24" />
|
||||||
|
</div>
|
||||||
|
<span class="ge-wand-more-wrap">
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-quickselect-more" aria-haspopup="true" aria-expanded="false" aria-label="More Quick Select options (help)" title="More Quick Select options (help)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
|
||||||
|
<div class="ge-wand-more-menu" id="ge-quickselect-more-menu" hidden>
|
||||||
|
<p class="ge-wand-tip">Paint over a region; the brush grows the selection to similar nearby pixels. Shift adds, Alt subtracts, Shift+Alt intersects.</p>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||||
|
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-quickselect-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm" id="ge-quickselect-clear" title="Clear selection" aria-label="Clear selection">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||||
|
Invert
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-delete" title="Erase selected pixels">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
|
||||||
|
Erase
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-copy" title="Copy selection to a new layer">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
Copy Layer
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-quickselect-mask" title="Add selection to mask">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
|
||||||
|
To Mask
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ge-objectselect-section" id="ge-objectselect-section" style="display:none;">
|
||||||
|
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the object selection combines with the current selection. Shift / Alt held during a stroke override this.">
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<span class="ge-wand-more-wrap">
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-wand-more-btn" id="ge-objectselect-more" aria-haspopup="true" aria-expanded="false" aria-label="More Object Select options (help)" title="More Object Select options (help)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
|
||||||
|
<div class="ge-wand-more-menu" id="ge-objectselect-more-menu" hidden>
|
||||||
|
<p class="ge-wand-tip">Draw over an object; the SAM model selects the whole object. Hold Alt to mark pixels to exclude. Shift adds, Esc clears.</p>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">
|
||||||
|
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-objectselect-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm" id="ge-objectselect-clear" title="Clear selection" aria-label="Clear selection">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></svg>
|
||||||
|
Invert
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-delete" title="Erase selected pixels">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4-4a2 2 0 0 1 0-3l9-9a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-9 9a2 2 0 0 1-3 0Z"/><path d="M22 21H7"/></svg>
|
||||||
|
Erase
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-copy" title="Copy selection to a new layer">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
Copy Layer
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-objectselect-mask" title="Add selection to mask">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="5"/></svg>
|
||||||
|
To Mask
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/><circle cx="12" cy="12" r="3.5"/></svg>Quick Mask</button>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click an object, or type a neutral object label. Shift adds, Alt subtracts.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;">
|
<div class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;">
|
||||||
<div class="ge-inpaint-popover-head" data-inpaint-drag>
|
<div class="ge-inpaint-popover-head" data-inpaint-drag>
|
||||||
@@ -448,72 +733,19 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
|||||||
</p>
|
</p>
|
||||||
<div class="ge-clone-source-status" id="ge-clone-source-status" aria-live="polite">
|
<div class="ge-clone-source-status" id="ge-clone-source-status" aria-live="polite">
|
||||||
<span id="ge-clone-source-label">No source selected</span>
|
<span id="ge-clone-source-label">No source selected</span>
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-clone-source-clear" title="Clear sampled source" aria-label="Clear sampled source">×</button>
|
<button type="button" class="ge-btn ge-btn-sm" id="ge-clone-source-clear" title="Clear sampled source" aria-label="Clear sampled source">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row">
|
<div class="ge-control-row">
|
||||||
<label for="ge-clone-sample-mode">Sample</label>
|
<label for="ge-clone-sample-mode">Sample</label>
|
||||||
<select id="ge-clone-sample-mode">
|
<select id="ge-clone-sample-mode" class="ge-ctl-select">
|
||||||
<option value="active-layer">Active layer</option>
|
<option value="active-layer">Active layer</option>
|
||||||
<option value="composite">All visible layers</option>
|
<option value="composite">All visible layers</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row ge-eraser-row">
|
|
||||||
<span class="ge-eraser-preview" id="ge-clone-preview-opacity" aria-hidden="true"></span>
|
|
||||||
<label>Opacity <span id="ge-clone-opacity-label">100%</span></label>
|
|
||||||
<input type="range" id="ge-clone-opacity" min="10" max="100" value="100" />
|
|
||||||
</div>
|
|
||||||
<div class="ge-control-row ge-eraser-row">
|
|
||||||
<span class="ge-eraser-preview" id="ge-clone-preview-flow" aria-hidden="true"></span>
|
|
||||||
<label>Flow <span id="ge-clone-flow-label">100%</span></label>
|
|
||||||
<input type="range" id="ge-clone-flow" min="5" max="100" value="100" />
|
|
||||||
</div>
|
|
||||||
<div class="ge-control-row ge-eraser-row">
|
|
||||||
<span class="ge-eraser-preview" id="ge-clone-preview-softness" aria-hidden="true"></span>
|
|
||||||
<label>Softness <span id="ge-clone-softness-label">100%</span></label>
|
|
||||||
<input type="range" id="ge-clone-softness" min="0" max="300" value="100" title="Soft brush edge — blurs each stamp for a feathered fade." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ge-eraser-section" id="ge-brush-section" style="display:none;">
|
|
||||||
<div class="ge-section-title"><span>Brush</span></div>
|
|
||||||
<div class="ge-control-row ge-eraser-row" id="ge-smudge-strength-row" style="display:none;">
|
|
||||||
<span class="ge-eraser-preview" id="ge-smudge-preview-strength" aria-hidden="true"></span>
|
|
||||||
<label>Strength <span id="ge-smudge-strength-label">65%</span></label>
|
|
||||||
<input type="range" id="ge-smudge-strength" min="5" max="100" value="65" title="How strongly Smudge carries sampled pixels into the stroke." />
|
|
||||||
</div>
|
|
||||||
<div class="ge-control-row ge-eraser-row">
|
|
||||||
<span class="ge-eraser-preview" id="ge-brush-preview-opacity" aria-hidden="true"></span>
|
|
||||||
<label>Opacity <span id="ge-brush-opacity-label">100%</span></label>
|
|
||||||
<input type="range" id="ge-brush-opacity" min="10" max="100" value="100" />
|
|
||||||
</div>
|
|
||||||
<div class="ge-control-row ge-eraser-row">
|
|
||||||
<span class="ge-eraser-preview" id="ge-brush-preview-flow" aria-hidden="true"></span>
|
|
||||||
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
|
|
||||||
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
|
|
||||||
</div>
|
|
||||||
<div class="ge-control-row ge-eraser-row">
|
|
||||||
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
|
|
||||||
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
|
|
||||||
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ge-eraser-section" id="ge-eraser-section" style="display:none;">
|
|
||||||
<div class="ge-section-title">Eraser</div>
|
|
||||||
<div class="ge-control-row ge-eraser-row">
|
|
||||||
<span class="ge-eraser-preview" id="ge-eraser-preview-opacity" aria-hidden="true"></span>
|
|
||||||
<label>Opacity <span id="ge-eraser-opacity-label">100%</span></label>
|
|
||||||
<input type="range" id="ge-eraser-opacity" min="10" max="100" value="100" />
|
|
||||||
</div>
|
|
||||||
<div class="ge-control-row ge-eraser-row">
|
|
||||||
<span class="ge-eraser-preview" id="ge-eraser-preview-flow" aria-hidden="true"></span>
|
|
||||||
<label>Flow <span id="ge-eraser-flow-label">100%</span></label>
|
|
||||||
<input type="range" id="ge-eraser-flow" min="5" max="100" value="100" />
|
|
||||||
</div>
|
|
||||||
<div class="ge-control-row ge-eraser-row">
|
|
||||||
<span class="ge-eraser-preview" id="ge-eraser-preview-softness" aria-hidden="true"></span>
|
|
||||||
<label>Softness <span id="ge-eraser-softness-label">100%</span></label>
|
|
||||||
<input type="range" id="ge-eraser-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha so the eraser fades out at the perimeter." />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ge-transform-section" id="ge-transform-section" style="display:none;"></div>
|
||||||
<div class="ge-sharpen-section" id="ge-sharpen-section" style="display:none;">
|
<div class="ge-sharpen-section" id="ge-sharpen-section" style="display:none;">
|
||||||
<div class="ge-control-row ge-eraser-row">
|
<div class="ge-control-row ge-eraser-row">
|
||||||
<span class="ge-eraser-preview" id="ge-sharpen-preview" aria-hidden="true"></span>
|
<span class="ge-eraser-preview" id="ge-sharpen-preview" aria-hidden="true"></span>
|
||||||
@@ -619,27 +851,6 @@ export function layerPanelHTML() {
|
|||||||
return `<div class="ge-layers-header">
|
return `<div class="ge-layers-header">
|
||||||
<span class="ge-layers-grab"></span>
|
<span class="ge-layers-grab"></span>
|
||||||
<span class="ge-layers-title">Layers</span>
|
<span class="ge-layers-title">Layers</span>
|
||||||
<div class="ge-layer-header-tools" role="group" aria-label="Layer actions">
|
|
||||||
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-add-layer" title="New layer" aria-label="New layer">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-down" title="Merge down" aria-label="Merge down">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-all" title="Merge all" aria-label="Merge all">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v6M9 6l3-3 3 3M3 14h18M12 14v7M9 18l3 3 3-3"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-flatten" title="Flatten copy (keeps originals)" aria-label="Flatten copy">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L4 6 L4 18 L12 22 L20 18 L20 6 Z"/><path d="M12 2 L12 22"/><path d="M4 6 L20 6"/><path d="M4 18 L20 18"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ge-btn ge-btn-sm" id="ge-layer-fill" title="Fill selected layer or selection with foreground color (Alt+Backspace)">Fill</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-layer-blend-row">
|
<div class="ge-layer-blend-row">
|
||||||
<label for="ge-layer-blend">Blend</label>
|
<label for="ge-layer-blend">Blend</label>
|
||||||
@@ -660,7 +871,21 @@ export function layerPanelHTML() {
|
|||||||
<option value="saturation">Saturation</option>
|
<option value="saturation">Saturation</option>
|
||||||
<option value="color">Color</option>
|
<option value="color">Color</option>
|
||||||
<option value="luminosity">Luminosity</option>
|
<option value="luminosity">Luminosity</option>
|
||||||
|
<option value="dissolve">Dissolve</option>
|
||||||
|
<option value="linear-burn">Linear Burn</option>
|
||||||
|
<option value="linear-dodge">Linear Dodge (Add)</option>
|
||||||
|
<option value="darker-color">Darker Color</option>
|
||||||
|
<option value="lighter-color">Lighter Color</option>
|
||||||
|
<option value="vivid-light">Vivid Light</option>
|
||||||
|
<option value="linear-light">Linear Light</option>
|
||||||
|
<option value="pin-light">Pin Light</option>
|
||||||
|
<option value="hard-mix">Hard Mix</option>
|
||||||
|
<option value="subtract">Subtract</option>
|
||||||
|
<option value="divide">Divide</option>
|
||||||
</select>
|
</select>
|
||||||
|
<label class="ge-layer-opacity-head" for="ge-layer-header-opacity" title="Active layer opacity">Opacity
|
||||||
|
<input type="number" id="ge-layer-header-opacity" min="0" max="100" step="1" value="100" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-layer-selection-bar" id="ge-layer-selection-bar" hidden>
|
<div class="ge-layer-selection-bar" id="ge-layer-selection-bar" hidden>
|
||||||
<span id="ge-layer-selection-count">2 layers</span>
|
<span id="ge-layer-selection-count">2 layers</span>
|
||||||
@@ -676,5 +901,31 @@ export function layerPanelHTML() {
|
|||||||
<button class="ge-icon-btn danger" id="ge-selected-delete" title="Delete selected layers" aria-label="Delete selected layers">×</button>
|
<button class="ge-icon-btn danger" id="ge-selected-delete" title="Delete selected layers" aria-label="Delete selected layers">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-layers-list" id="ge-layers-list"></div>
|
<div class="ge-layers-list" id="ge-layers-list"></div>
|
||||||
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>`;
|
<div class="ge-layers-footer">
|
||||||
|
<div class="ge-layer-header-tools" role="group" aria-label="Layer actions">
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-add-layer" title="New layer" aria-label="New layer">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn ge-add-fx" id="ge-add-fx" title="New adjustment (fx) layer" aria-label="New adjustment layer">
|
||||||
|
<span class="ge-add-fx-glyph" aria-hidden="true">fx</span>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-down" title="Merge down" aria-label="Merge down">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-all" title="Merge all" aria-label="Merge all">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v6M9 6l3-3 3 3M3 14h18M12 14v7M9 18l3 3 3-3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-flatten" title="Flatten copy (keeps originals)" aria-label="Flatten copy">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L4 6 L4 18 L12 22 L20 18 L20 6 Z"/><path d="M12 2 L12 22"/><path d="M4 6 L20 6"/><path d="M4 18 L20 18"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ge-btn ge-btn-sm" id="ge-layer-fill" title="Fill selected layer or selection with foreground color (Alt+Backspace)">Fill</button>
|
||||||
|
</div>
|
||||||
|
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
/**
|
||||||
|
* Docked panel stack (PhotoCraft-parity phase 0/3).
|
||||||
|
*
|
||||||
|
* Wraps a list of existing panel elements into a Photoshop-style dock:
|
||||||
|
* several panel GROUPS are visible at once, stacked vertically. Each
|
||||||
|
* group has clickable tabs (one panel visible per group), a collapse
|
||||||
|
* control that shrinks it to its tab strip, and a drag divider so the
|
||||||
|
* user can resize the group above.
|
||||||
|
*
|
||||||
|
* Purely presentational: it never owns editor state. Each entry is an
|
||||||
|
* element that already exists elsewhere (so all existing ids, classes
|
||||||
|
* and event listeners keep working). Switching a tab only toggles the
|
||||||
|
* `hidden` attribute on the panel wrapper.
|
||||||
|
*
|
||||||
|
* A compact `<select>` variant (`compact: true`) is kept as a fallback
|
||||||
|
* for narrow columns where a multi-group stack will not fit.
|
||||||
|
*
|
||||||
|
* @param {{
|
||||||
|
* entries: Array<{id: string, label: string, element: HTMLElement, group?: string}>,
|
||||||
|
* persistKey?: string,
|
||||||
|
* defaultId?: string,
|
||||||
|
* compact?: boolean,
|
||||||
|
* }} opts
|
||||||
|
* @returns {{ dock: HTMLDivElement, show: (id: string) => void, readonly activeId: string|null }}
|
||||||
|
*/
|
||||||
|
export function buildDock({ entries = [], persistKey = 'ge-dock-active', defaultId = null, compact = false, onCloseGroup = null } = {}) {
|
||||||
|
const dock = document.createElement('div');
|
||||||
|
dock.className = 'ge-dock';
|
||||||
|
if (compact) dock.classList.add('ge-dock--compact');
|
||||||
|
|
||||||
|
// Collect entries into ordered groups (Photoshop panel groups).
|
||||||
|
const groups = [];
|
||||||
|
entries.forEach(entry => {
|
||||||
|
const name = entry.group || 'Panels';
|
||||||
|
let group = groups.find(g => g.name === name);
|
||||||
|
if (!group) {
|
||||||
|
group = { name, entries: [], size: entry.groupSize || null, collapsed: !!entry.groupCollapsed };
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
group.entries.push(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupOfId = new Map();
|
||||||
|
groups.forEach(group => group.entries.forEach(e => groupOfId.set(e.id, group)));
|
||||||
|
|
||||||
|
let saved = {};
|
||||||
|
try { saved = JSON.parse(localStorage.getItem(persistKey) || '{}') || {}; } catch {}
|
||||||
|
const activeByGroup = new Map();
|
||||||
|
groups.forEach(group => {
|
||||||
|
const fallback = group.entries[0]?.id || null;
|
||||||
|
const savedId = saved[group.name];
|
||||||
|
const pick = (savedId && group.entries.some(e => e.id === savedId)) ? savedId
|
||||||
|
: (defaultId && group.entries.some(e => e.id === defaultId)) ? defaultId
|
||||||
|
: fallback;
|
||||||
|
activeByGroup.set(group.name, pick);
|
||||||
|
});
|
||||||
|
|
||||||
|
let activeId = null;
|
||||||
|
const persist = () => {
|
||||||
|
const out = {};
|
||||||
|
activeByGroup.forEach((v, k) => { if (v) out[k] = v; });
|
||||||
|
try { localStorage.setItem(persistKey, JSON.stringify(out)); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupEls = new Map();
|
||||||
|
const detachPlacers = new Map();
|
||||||
|
const activateInGroup = (group, id) => {
|
||||||
|
activeByGroup.set(group.name, id);
|
||||||
|
activeId = id;
|
||||||
|
groupEls.get(group.name)?.querySelectorAll('.ge-dock-tab').forEach(tab => {
|
||||||
|
const on = tab.dataset.panel === id;
|
||||||
|
tab.classList.toggle('active', on);
|
||||||
|
tab.setAttribute('aria-selected', String(on));
|
||||||
|
});
|
||||||
|
group.entries.forEach(entry => {
|
||||||
|
const panel = groupEls.get(group.name)?.querySelector(`.ge-dock-panel[data-panel="${entry.id}"]`);
|
||||||
|
if (panel) panel.hidden = entry.id !== id;
|
||||||
|
});
|
||||||
|
// Keep the group's undock control anchored beside the active tab.
|
||||||
|
detachPlacers.get(group.name)?.();
|
||||||
|
persist();
|
||||||
|
};
|
||||||
|
|
||||||
|
function show(id) {
|
||||||
|
const group = groupOfId.get(id);
|
||||||
|
if (!group) return;
|
||||||
|
groupEls.get(group.name)?.classList.remove('is-collapsed');
|
||||||
|
activateInGroup(group, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dock tab plus a small "undock" control (Photoshop lets you tear a single
|
||||||
|
// panel tab out of its group). Used both for the initial build and when a
|
||||||
|
// floated panel is re-docked.
|
||||||
|
function makeTabItem(group, entry, groupEl) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'ge-dock-tab-item';
|
||||||
|
item.dataset.panel = entry.id;
|
||||||
|
const tab = document.createElement('button');
|
||||||
|
tab.type = 'button';
|
||||||
|
tab.className = 'ge-dock-tab';
|
||||||
|
tab.dataset.panel = entry.id;
|
||||||
|
tab.textContent = entry.label;
|
||||||
|
tab.setAttribute('role', 'tab');
|
||||||
|
tab.setAttribute('aria-selected', 'false');
|
||||||
|
tab.title = entry.label;
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
groupEl.classList.remove('is-collapsed');
|
||||||
|
activateInGroup(group, entry.id);
|
||||||
|
});
|
||||||
|
item.appendChild(tab);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Float a single panel into its own draggable window (Photoshop undock-tab).
|
||||||
|
function floatPanel(group, entry) {
|
||||||
|
const groupEl = groupEls.get(group.name);
|
||||||
|
if (!groupEl) return;
|
||||||
|
const panelEl = groupEl.querySelector(`.ge-dock-panel[data-panel="${entry.id}"]`);
|
||||||
|
if (!panelEl || panelEl.closest('.ge-float-window')) return;
|
||||||
|
const win = document.createElement('div');
|
||||||
|
win.className = 'ge-float-window';
|
||||||
|
win.innerHTML = `
|
||||||
|
<div class="ge-float-head">
|
||||||
|
<span class="ge-float-title">${entry.label}</span>
|
||||||
|
<span class="ge-float-btns">
|
||||||
|
<button class="ge-float-dock" type="button" title="Dock panel" aria-label="Dock panel">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ge-float-body"></div>`;
|
||||||
|
win.querySelector('.ge-float-body').appendChild(panelEl);
|
||||||
|
panelEl.hidden = false;
|
||||||
|
document.body.appendChild(win);
|
||||||
|
const r = groupEl.getBoundingClientRect();
|
||||||
|
win.style.left = `${Math.max(8, Math.min(window.innerWidth - 240, r.left - 260))}px`;
|
||||||
|
win.style.top = `${Math.max(8, r.top)}px`;
|
||||||
|
makeFloatDraggable(win, win.querySelector('.ge-float-head'));
|
||||||
|
const tabsEl = groupEl.querySelector('.ge-dock-tabs');
|
||||||
|
const itemEl = tabsEl?.querySelector(`.ge-dock-tab-item[data-panel="${entry.id}"]`);
|
||||||
|
itemEl?.remove();
|
||||||
|
const wasActive = activeId === entry.id;
|
||||||
|
win.querySelector('.ge-float-dock').addEventListener('click', () => {
|
||||||
|
const bodyEl = groupEl.querySelector('.ge-dock-panels');
|
||||||
|
if (bodyEl) bodyEl.appendChild(panelEl); else groupEl.appendChild(panelEl);
|
||||||
|
panelEl.hidden = activeId !== entry.id;
|
||||||
|
win.remove();
|
||||||
|
if (tabsEl) tabsEl.appendChild(makeTabItem(group, entry, groupEl));
|
||||||
|
if (wasActive) activateInGroup(group, entry.id);
|
||||||
|
detachPlacers.get(group.name)?.();
|
||||||
|
syncTabsOverflow(groupEl);
|
||||||
|
});
|
||||||
|
if (wasActive) {
|
||||||
|
const next = group.entries.find(e => {
|
||||||
|
const p = groupEl.querySelector(`.ge-dock-panel[data-panel="${e.id}"]`);
|
||||||
|
return p && !p.closest('.ge-float-window');
|
||||||
|
});
|
||||||
|
if (next) activateInGroup(group, next.id);
|
||||||
|
}
|
||||||
|
syncTabsOverflow(groupEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
const tabbar = document.createElement('div');
|
||||||
|
tabbar.className = 'ge-dock-tabbar';
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'ge-dock-select';
|
||||||
|
select.setAttribute('aria-label', 'Editor panel');
|
||||||
|
const optgroups = new Map();
|
||||||
|
entries.forEach(({ id, label, group }) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = id;
|
||||||
|
option.textContent = label;
|
||||||
|
if (group) {
|
||||||
|
let optgroup = optgroups.get(group);
|
||||||
|
if (!optgroup) {
|
||||||
|
optgroup = document.createElement('optgroup');
|
||||||
|
optgroup.label = group;
|
||||||
|
optgroups.set(group, optgroup);
|
||||||
|
select.appendChild(optgroup);
|
||||||
|
}
|
||||||
|
optgroup.appendChild(option);
|
||||||
|
} else {
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tabbar.appendChild(select);
|
||||||
|
const panels = document.createElement('div');
|
||||||
|
panels.className = 'ge-dock-panels';
|
||||||
|
entries.forEach(({ id, element }) => {
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'ge-dock-panel';
|
||||||
|
panel.dataset.panel = id;
|
||||||
|
if (element) panel.appendChild(element);
|
||||||
|
panels.appendChild(panel);
|
||||||
|
});
|
||||||
|
dock.appendChild(tabbar);
|
||||||
|
dock.appendChild(panels);
|
||||||
|
|
||||||
|
const applyFlat = (id) => {
|
||||||
|
activeId = id;
|
||||||
|
panels.querySelectorAll('.ge-dock-panel').forEach(p => { p.hidden = p.dataset.panel !== id; });
|
||||||
|
const group = groupOfId.get(id);
|
||||||
|
if (group) activeByGroup.set(group.name, id);
|
||||||
|
persist();
|
||||||
|
};
|
||||||
|
select.addEventListener('change', () => applyFlat(select.value));
|
||||||
|
const initial = defaultId || entries[0]?.id || null;
|
||||||
|
if (initial) { select.value = initial; applyFlat(initial); }
|
||||||
|
return { dock, show, get activeId() { return activeId; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stacked mode: one `.ge-dock-group` per panel group, with a drag
|
||||||
|
// divider between groups (skip a leading divider).
|
||||||
|
groups.forEach((group, groupIndex) => {
|
||||||
|
if (groupIndex > 0) {
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'ge-dock-divider';
|
||||||
|
divider.setAttribute('role', 'separator');
|
||||||
|
divider.setAttribute('aria-orientation', 'horizontal');
|
||||||
|
divider.title = 'Drag to resize · double-click to expand/collapse a panel group';
|
||||||
|
dock.appendChild(divider);
|
||||||
|
wireDivider(divider, groupIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupEl = document.createElement('div');
|
||||||
|
groupEl.className = 'ge-dock-group';
|
||||||
|
groupEl.dataset.group = group.name;
|
||||||
|
if (group.size) {
|
||||||
|
groupEl.style.flex = `0 0 ${group.size}px`;
|
||||||
|
groupEl.style.height = `${group.size}px`;
|
||||||
|
}
|
||||||
|
if (group.collapsed) groupEl.classList.add('is-collapsed');
|
||||||
|
|
||||||
|
const head = document.createElement('div');
|
||||||
|
head.className = 'ge-dock-group-head';
|
||||||
|
|
||||||
|
const tabs = document.createElement('div');
|
||||||
|
tabs.className = 'ge-dock-tabs';
|
||||||
|
tabs.setAttribute('role', 'tablist');
|
||||||
|
group.entries.forEach(entry => {
|
||||||
|
tabs.appendChild(makeTabItem(group, entry, groupEl));
|
||||||
|
});
|
||||||
|
|
||||||
|
const collapse = document.createElement('button');
|
||||||
|
collapse.type = 'button';
|
||||||
|
collapse.className = 'ge-dock-collapse';
|
||||||
|
collapse.title = 'Collapse panel group';
|
||||||
|
collapse.setAttribute('aria-label', 'Collapse panel group');
|
||||||
|
collapse.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||||
|
collapse.addEventListener('click', () => {
|
||||||
|
const collapsed = groupEl.classList.toggle('is-collapsed');
|
||||||
|
collapse.title = collapsed ? 'Expand panel group' : 'Collapse panel group';
|
||||||
|
collapse.setAttribute('aria-label', collapse.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overflow affordance: when a group's tabs do not fit the column,
|
||||||
|
// show a chevron that opens a menu of every tab so none become
|
||||||
|
// unreachable or unreadable (Photoshop-style tab overflow).
|
||||||
|
const tabsMore = document.createElement('button');
|
||||||
|
tabsMore.type = 'button';
|
||||||
|
tabsMore.className = 'ge-dock-tabs-more';
|
||||||
|
tabsMore.title = 'More panels';
|
||||||
|
tabsMore.setAttribute('aria-label', 'More panels');
|
||||||
|
tabsMore.hidden = true;
|
||||||
|
tabsMore.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="6" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="18" cy="12" r="1.9"/></svg>';
|
||||||
|
tabsMore.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openTabsMenu(tabsMore, group, groupEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
head.appendChild(tabs);
|
||||||
|
head.appendChild(tabsMore);
|
||||||
|
head.appendChild(collapse);
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'ge-dock-panels';
|
||||||
|
group.entries.forEach(entry => {
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'ge-dock-panel';
|
||||||
|
panel.dataset.panel = entry.id;
|
||||||
|
if (entry.element) panel.appendChild(entry.element);
|
||||||
|
body.appendChild(panel);
|
||||||
|
});
|
||||||
|
|
||||||
|
groupEl.appendChild(head);
|
||||||
|
groupEl.appendChild(body);
|
||||||
|
dock.appendChild(groupEl);
|
||||||
|
groupEls.set(group.name, groupEl);
|
||||||
|
syncTabsOverflow(groupEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-evaluate tab overflow whenever the column width changes, and keep any
|
||||||
|
// torn-off (floating) panels on-screen after a viewport resize so they never
|
||||||
|
// drift off the edge or vanish.
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
groupEls.forEach(el => syncTabsOverflow(el));
|
||||||
|
document.querySelectorAll('.ge-float-window').forEach((w) => {
|
||||||
|
const r = w.getBoundingClientRect();
|
||||||
|
const maxL = Math.max(8, window.innerWidth - r.width - 8);
|
||||||
|
const maxT = Math.max(8, window.innerHeight - 48);
|
||||||
|
w.style.left = Math.min(Math.max(8, r.left), maxL) + 'px';
|
||||||
|
w.style.top = Math.min(Math.max(8, r.top), maxT) + 'px';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The dock is built before it is attached to the DOM (so its width is 0 at
|
||||||
|
// build time and the overflow check above cannot see the real column width).
|
||||||
|
// Observe size changes to recompute overflow once it is laid out and whenever
|
||||||
|
// the panel is resized, collapsed or expanded — otherwise a tab strip that
|
||||||
|
// overflows keeps the chevron hidden and clips its trailing labels.
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const ro = new ResizeObserver(() => groupEls.forEach(el => syncTabsOverflow(el)));
|
||||||
|
ro.observe(dock);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => groupEls.forEach(el => syncTabsOverflow(el)));
|
||||||
|
|
||||||
|
function syncTabsOverflow(groupEl) {
|
||||||
|
const tabsEl = groupEl.querySelector('.ge-dock-tabs');
|
||||||
|
const moreEl = groupEl.querySelector('.ge-dock-tabs-more');
|
||||||
|
if (!tabsEl || !moreEl) return;
|
||||||
|
const items = Array.from(tabsEl.querySelectorAll('.ge-dock-tab-item'));
|
||||||
|
// Reset any previous hiding so widths are measured at their natural size.
|
||||||
|
items.forEach(item => { item.style.display = ''; });
|
||||||
|
const overflowing = tabsEl.scrollWidth - tabsEl.clientWidth > 1;
|
||||||
|
// Keep the menu reachable even when the tabs fit, so its "Close <group>"
|
||||||
|
// action (and every tab) stays available for every section.
|
||||||
|
moreEl.hidden = !(overflowing || typeof onCloseGroup === 'function');
|
||||||
|
if (!overflowing) return;
|
||||||
|
// Photoshop-style tab overflow: rather than clipping a label mid-word
|
||||||
|
// ("PATTERNS" -> "P…", "CHARACTER" -> "CHARA"), keep only the tabs that
|
||||||
|
// fit whole and let the chevron expose the rest. The active tab is always
|
||||||
|
// kept visible so the current panel never loses its anchor.
|
||||||
|
const active = tabsEl.querySelector('.ge-dock-tab.active');
|
||||||
|
const activeItem = active ? active.closest('.ge-dock-tab-item') : null;
|
||||||
|
const limit = tabsEl.clientWidth;
|
||||||
|
let gap = 0;
|
||||||
|
try { gap = parseFloat(getComputedStyle(tabsEl).columnGap) || 0; } catch {}
|
||||||
|
// Keep the leading run of tabs that fits, then tuck the trailing tabs
|
||||||
|
// (and everything after the first that no longer fits) into the chevron.
|
||||||
|
// The active tab is always kept visible so the current panel stays anchored.
|
||||||
|
let used = 0;
|
||||||
|
let cutting = false;
|
||||||
|
items.forEach(item => {
|
||||||
|
const w = item.getBoundingClientRect().width;
|
||||||
|
if (item === activeItem || (!cutting && used + w <= limit + 0.5)) {
|
||||||
|
used += w + gap;
|
||||||
|
} else {
|
||||||
|
cutting = true;
|
||||||
|
item.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabsMenu = null;
|
||||||
|
function closeTabsMenu() {
|
||||||
|
if (!tabsMenu) return;
|
||||||
|
tabsMenu.remove();
|
||||||
|
tabsMenu = null;
|
||||||
|
document.removeEventListener('pointerdown', onTabsMenuDocDown, true);
|
||||||
|
}
|
||||||
|
function onTabsMenuDocDown(e) {
|
||||||
|
if (tabsMenu && !tabsMenu.contains(e.target) && !e.target.closest?.('.ge-dock-tabs-more')) closeTabsMenu();
|
||||||
|
}
|
||||||
|
function openTabsMenu(anchor, group, groupEl) {
|
||||||
|
closeTabsMenu();
|
||||||
|
tabsMenu = document.createElement('div');
|
||||||
|
tabsMenu.className = 'ge-dock-tabs-menu';
|
||||||
|
group.entries.forEach(entry => {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.type = 'button';
|
||||||
|
item.className = 'ge-dock-tabs-menu-item';
|
||||||
|
item.textContent = entry.label;
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
groupEl.classList.remove('is-collapsed');
|
||||||
|
activateInGroup(group, entry.id);
|
||||||
|
syncTabsOverflow(groupEl);
|
||||||
|
closeTabsMenu();
|
||||||
|
});
|
||||||
|
tabsMenu.appendChild(item);
|
||||||
|
});
|
||||||
|
// Close the whole section (panel group) from the same menu — Photoshop's
|
||||||
|
// "Close Tab Group". Only offered when the host supplies a handler.
|
||||||
|
if (typeof onCloseGroup === 'function') {
|
||||||
|
const sep = document.createElement('div');
|
||||||
|
sep.className = 'ge-dock-tabs-menu-sep';
|
||||||
|
tabsMenu.appendChild(sep);
|
||||||
|
const closeItem = document.createElement('button');
|
||||||
|
closeItem.type = 'button';
|
||||||
|
closeItem.className = 'ge-dock-tabs-menu-item ge-dock-tabs-menu-close';
|
||||||
|
closeItem.textContent = `Close ${group.name}`;
|
||||||
|
closeItem.addEventListener('click', () => {
|
||||||
|
closeTabsMenu();
|
||||||
|
try { onCloseGroup(group.name); } catch (_) {}
|
||||||
|
});
|
||||||
|
tabsMenu.appendChild(closeItem);
|
||||||
|
}
|
||||||
|
document.body.appendChild(tabsMenu);
|
||||||
|
const r = anchor.getBoundingClientRect();
|
||||||
|
tabsMenu.style.left = `${Math.max(4, Math.min(window.innerWidth - 160, r.left))}px`;
|
||||||
|
tabsMenu.style.top = `${r.bottom + 2}px`;
|
||||||
|
document.addEventListener('pointerdown', onTabsMenuDocDown, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireDivider(divider, groupIndex) {
|
||||||
|
const groupEl = () => dock.querySelectorAll('.ge-dock-group')[groupIndex - 1];
|
||||||
|
let startY = 0;
|
||||||
|
let startH = 0;
|
||||||
|
let resizing = false;
|
||||||
|
let moved = false;
|
||||||
|
divider.addEventListener('pointerdown', (event) => {
|
||||||
|
const target = groupEl();
|
||||||
|
if (!target) return;
|
||||||
|
resizing = true;
|
||||||
|
moved = false;
|
||||||
|
startY = event.clientY;
|
||||||
|
startH = target.getBoundingClientRect().height;
|
||||||
|
divider.setPointerCapture(event.pointerId);
|
||||||
|
document.body.style.cursor = 'ns-resize';
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
divider.addEventListener('pointermove', (event) => {
|
||||||
|
if (!resizing) return;
|
||||||
|
const target = groupEl();
|
||||||
|
if (!target) return;
|
||||||
|
// A real drag supersedes any double-click expand state (a plain click,
|
||||||
|
// which a double-click fires, must not, or the toggle would never fire).
|
||||||
|
if (!moved) { moved = true; target.dataset.dockExpanded = '0'; target.classList.remove('is-expanded'); }
|
||||||
|
const next = Math.max(28, startH + (event.clientY - startY));
|
||||||
|
// Fixed basis (no grow/shrink): a `1 1` basis lets the height-constrained
|
||||||
|
// dock shrink the dragged group straight back to 0, so the divider looked
|
||||||
|
// dead. Keeping it `0 0` holds the size and lets the dock scroll instead.
|
||||||
|
target.style.flex = `0 0 ${Math.round(next)}px`;
|
||||||
|
target.style.height = `${Math.round(next)}px`;
|
||||||
|
});
|
||||||
|
const end = (event) => {
|
||||||
|
if (!resizing) return;
|
||||||
|
resizing = false;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
try { divider.releasePointerCapture?.(event.pointerId); } catch {}
|
||||||
|
};
|
||||||
|
divider.addEventListener('pointerup', end);
|
||||||
|
divider.addEventListener('pointercancel', end);
|
||||||
|
// Double-click toggles the group above between "show everything" and the
|
||||||
|
// previous size. Handy on a cramped sidebar: one double-click unrolls the
|
||||||
|
// whole group (its body is normally height-clamped and scrolls), another
|
||||||
|
// restores exactly what it was.
|
||||||
|
divider.addEventListener('dblclick', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const target = groupEl();
|
||||||
|
if (!target) return;
|
||||||
|
const body = target.querySelector('.ge-dock-panels');
|
||||||
|
if (target.dataset.dockExpanded === '1') {
|
||||||
|
const prev = parseFloat(target.dataset.dockPrevSize || '');
|
||||||
|
const restore = Number.isFinite(prev) && prev > 0 ? prev : 200;
|
||||||
|
target.style.flex = `0 0 ${Math.round(restore)}px`;
|
||||||
|
target.style.height = `${Math.round(restore)}px`;
|
||||||
|
target.dataset.dockExpanded = '0';
|
||||||
|
target.classList.remove('is-expanded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const head = target.querySelector('.ge-dock-tabs');
|
||||||
|
const headH = head ? head.getBoundingClientRect().height : 0;
|
||||||
|
const bodyH = body ? body.scrollHeight : 0;
|
||||||
|
const full = Math.ceil(headH + bodyH + 2);
|
||||||
|
target.dataset.dockPrevSize = String(Math.round(target.getBoundingClientRect().height));
|
||||||
|
target.classList.remove('is-collapsed');
|
||||||
|
target.style.flex = `0 0 ${full}px`;
|
||||||
|
target.style.height = `${full}px`;
|
||||||
|
target.dataset.dockExpanded = '1';
|
||||||
|
target.classList.add('is-expanded');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move a panel group out of the dock into a floating, draggable
|
||||||
|
// window; the window offers a "Dock" control that returns the exact
|
||||||
|
// same `.ge-dock-group` node (DOM identity preserved) to its original
|
||||||
|
// position in the stack.
|
||||||
|
function detachGroup(group, groupEl, dockEl) {
|
||||||
|
if (groupEl.classList.contains('is-floating')) return;
|
||||||
|
const prevDivider = groupEl.previousElementSibling;
|
||||||
|
const hadDivider = !!prevDivider && prevDivider.classList.contains('ge-dock-divider');
|
||||||
|
if (hadDivider) prevDivider.hidden = true;
|
||||||
|
const dockIndex = Array.from(dockEl.querySelectorAll('.ge-dock-group')).indexOf(groupEl);
|
||||||
|
groupEl.classList.add('is-floating');
|
||||||
|
const win = document.createElement('div');
|
||||||
|
win.className = 'ge-float-window';
|
||||||
|
win.dataset.group = group.name;
|
||||||
|
win.innerHTML = `
|
||||||
|
<div class="ge-float-head">
|
||||||
|
<span class="ge-float-title">${group.name}</span>
|
||||||
|
<span class="ge-float-btns">
|
||||||
|
<button class="ge-float-dock" type="button" title="Dock panel group" aria-label="Dock panel group">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ge-float-body"></div>`;
|
||||||
|
win.querySelector('.ge-float-body').appendChild(groupEl);
|
||||||
|
document.body.appendChild(win);
|
||||||
|
const r = groupEl.getBoundingClientRect();
|
||||||
|
win.style.left = `${Math.max(8, Math.min(window.innerWidth - 260, r.left - 60))}px`;
|
||||||
|
win.style.top = `${Math.max(8, r.top)}px`;
|
||||||
|
makeFloatDraggable(win, win.querySelector('.ge-float-head'));
|
||||||
|
win.querySelector('.ge-float-dock').addEventListener('click', () => {
|
||||||
|
const groupsNow = Array.from(dockEl.querySelectorAll('.ge-dock-group'));
|
||||||
|
const anchor = groupsNow[dockIndex] || null;
|
||||||
|
if (anchor) dockEl.insertBefore(groupEl, anchor); else dockEl.appendChild(groupEl);
|
||||||
|
if (hadDivider) prevDivider.hidden = false;
|
||||||
|
groupEl.classList.remove('is-floating');
|
||||||
|
win.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFloatDraggable(win, handle) {
|
||||||
|
if (!handle) return;
|
||||||
|
handle.style.touchAction = 'none';
|
||||||
|
handle.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.target.closest('button')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const startX = e.clientX, startY = e.clientY;
|
||||||
|
const r0 = win.getBoundingClientRect();
|
||||||
|
try { handle.setPointerCapture(e.pointerId); } catch {}
|
||||||
|
handle.style.cursor = 'grabbing';
|
||||||
|
const onMove = (ev) => {
|
||||||
|
win.style.left = `${Math.max(0, Math.min(window.innerWidth - 80, r0.left + (ev.clientX - startX)))}px`;
|
||||||
|
win.style.top = `${Math.max(0, Math.min(window.innerHeight - 30, r0.top + (ev.clientY - startY)))}px`;
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
try { handle.releasePointerCapture(e.pointerId); } catch {}
|
||||||
|
handle.style.cursor = '';
|
||||||
|
handle.removeEventListener('pointermove', onMove);
|
||||||
|
handle.removeEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', onMove);
|
||||||
|
handle.addEventListener('pointerup', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
groupEls.forEach((groupEl, name) => {
|
||||||
|
const group = groups.find(g => g.name === name);
|
||||||
|
const id = activeByGroup.get(name) || group?.entries[0]?.id;
|
||||||
|
if (id) activateInGroup(group, id);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
dock,
|
||||||
|
show,
|
||||||
|
get activeId() { return activeId; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Contextual tool options bar — a Photoshop-style strip that sits
|
||||||
|
* directly under the topbar and shows quick controls for the active
|
||||||
|
* tool.
|
||||||
|
*
|
||||||
|
* This is a *mirror* of the authoritative controls in the right-hand
|
||||||
|
* panel: it never owns state. Every value is delegated to the existing
|
||||||
|
* input element (by selector) so the two views can never disagree —
|
||||||
|
* editing here re-dispatches the same `input`/`change` event the real
|
||||||
|
* control listens for, and the real control pushes updates back into
|
||||||
|
* the bar.
|
||||||
|
*
|
||||||
|
* On phones (<= 820px) the bar is hidden by CSS and the right-panel
|
||||||
|
* bottom sheet remains the single surface, exactly as before.
|
||||||
|
*/
|
||||||
|
import { TOOLS } from './toolbar.js?v=20261010brushpresets';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
|
||||||
|
// Per-tool option sources. Each value is a CSS selector for an existing
|
||||||
|
// control inside the right panel; `null` means "no such control".
|
||||||
|
const BRUSH_TOOLS = {
|
||||||
|
brush: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
|
||||||
|
smudge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
|
||||||
|
dodge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness', intensity: '#ge-dodgeburn-intensity' },
|
||||||
|
burn: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness', intensity: '#ge-dodgeburn-intensity' },
|
||||||
|
eraser: { size: '.ge-size-slider', opacity: '#ge-eraser-opacity', flow: '#ge-eraser-flow', softness: '#ge-eraser-softness' },
|
||||||
|
clone: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
|
||||||
|
heal: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SELECTION_TOOLS = {
|
||||||
|
marquee: { modes: null, feather: null },
|
||||||
|
lasso: { modes: null, feather: '#ge-lasso-feather' },
|
||||||
|
wand: { modes: null, feather: '#ge-wand-feather' },
|
||||||
|
quickselect: { modes: null, feather: null },
|
||||||
|
objectselect: { modes: null, feather: null },
|
||||||
|
sam: { modes: null, feather: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Liquify is not a brush: it exposes its own mode plus dab size and strength.
|
||||||
|
const LIQUIFY_TOOLS = {
|
||||||
|
liquify: { size: '#ge-liquify-size', strength: '#ge-liquify-strength' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTERSECT_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="position:relative;top:2px"><circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/></svg>';
|
||||||
|
const MODE_LABELS = [['replace', 'New'], ['add', '+'], ['subtract', '−'], ['intersect', INTERSECT_ICON]];
|
||||||
|
|
||||||
|
function resolve(selector) {
|
||||||
|
if (!selector) return null;
|
||||||
|
return selector.startsWith('#')
|
||||||
|
? document.getElementById(selector.slice(1))
|
||||||
|
: document.querySelector(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRange(label, sourceSelector, { suffix = '' } = {}) {
|
||||||
|
const source = resolve(sourceSelector);
|
||||||
|
const value = source ? source.value : '0';
|
||||||
|
const min = source?.getAttribute('min') ?? '0';
|
||||||
|
const max = source?.getAttribute('max') ?? '100';
|
||||||
|
const step = source?.getAttribute('step') ?? '1';
|
||||||
|
return `
|
||||||
|
<div class="ge-opt" data-opt="range">
|
||||||
|
<label class="ge-opt-label">${label}<span class="ge-opt-value" data-opt-value>--</span></label>
|
||||||
|
<input type="range" data-opt-range data-src="${sourceSelector}" min="${min}" max="${max}" step="${step}" value="${value}" />
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSelect(label, sourceSelector) {
|
||||||
|
const source = resolve(sourceSelector);
|
||||||
|
const options = source
|
||||||
|
? [...source.options].map(o => `<option value="${o.value}">${o.textContent}</option>`).join('')
|
||||||
|
: '';
|
||||||
|
const value = source ? source.value : '';
|
||||||
|
return `
|
||||||
|
<div class="ge-opt" data-opt="select">
|
||||||
|
<label class="ge-opt-label">${label}</label>
|
||||||
|
<select data-opt-select data-src="${sourceSelector}">${options}</select>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAutoSelect() {
|
||||||
|
return `
|
||||||
|
<label class="ge-opt ge-auto-select-option" data-opt="check">
|
||||||
|
<input type="checkbox" id="ge-auto-select-layer" class="ge-ctl-check" />
|
||||||
|
<span class="ge-opt-label">Auto-Select</span>
|
||||||
|
</label>
|
||||||
|
<div class="ge-opt" data-opt="select">
|
||||||
|
<label class="ge-opt-label" for="ge-auto-select-target">Target</label>
|
||||||
|
<select id="ge-auto-select-target">
|
||||||
|
<option value="layer">Layer</option>
|
||||||
|
<option value="group">Group</option>
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeModes(sectionSelector) {
|
||||||
|
const buttons = MODE_LABELS.map(([mode, text]) =>
|
||||||
|
`<button type="button" class="ge-opt-mode" data-opt-mode="${mode}" data-section="${sectionSelector}">${text}</button>`
|
||||||
|
).join('');
|
||||||
|
return `<div class="ge-opt" data-opt="modes"><span class="ge-opt-label">Mode</span><span class="ge-opt-modes">${buttons}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolControls(toolId) {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (BRUSH_TOOLS[toolId]) {
|
||||||
|
const src = BRUSH_TOOLS[toolId];
|
||||||
|
parts.push(makeRange('Size', src.size, { suffix: 'px' }));
|
||||||
|
parts.push(makeRange('Opacity', src.opacity));
|
||||||
|
parts.push(makeRange('Flow', src.flow));
|
||||||
|
parts.push(makeRange('Softness', src.softness));
|
||||||
|
if (src.intensity) parts.push(makeRange('Intensity', src.intensity));
|
||||||
|
} else if (SELECTION_TOOLS[toolId]) {
|
||||||
|
const src = SELECTION_TOOLS[toolId];
|
||||||
|
if (src.modes) parts.push(makeModes(src.modes));
|
||||||
|
if (src.feather) parts.push(makeRange('Feather', src.feather, { suffix: 'px' }));
|
||||||
|
} else if (LIQUIFY_TOOLS[toolId]) {
|
||||||
|
const src = LIQUIFY_TOOLS[toolId];
|
||||||
|
parts.push(makeSelect('Mode', '#ge-liquify-mode'));
|
||||||
|
parts.push(makeRange('Size', src.size, { suffix: 'px' }));
|
||||||
|
parts.push(makeRange('Strength', src.strength));
|
||||||
|
} else if (toolId === 'move') {
|
||||||
|
parts.push(makeAutoSelect());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parts.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueLabelOf(source, value) {
|
||||||
|
// Prefer the sibling label the right panel already maintains so the
|
||||||
|
// two surfaces always show the same formatted number.
|
||||||
|
const row = source?.closest?.('.ge-eraser-row, .ge-control-row');
|
||||||
|
const label = row?.querySelector('span[id$="-label"], span[id$="-opacity-label"], span[id$="-flow-label"], span[id$="-softness-label"]');
|
||||||
|
const text = label?.textContent?.trim();
|
||||||
|
return text || (typeof value === 'string' ? value : String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh every mirrored control inside `bar` from its source. */
|
||||||
|
export function syncOptionsBar(bar) {
|
||||||
|
if (!bar) return;
|
||||||
|
bar.querySelectorAll('[data-src]').forEach(el => {
|
||||||
|
const source = resolve(el.dataset.src);
|
||||||
|
if (!source) return;
|
||||||
|
if (el.tagName === 'SELECT') { el.value = source.value; return; }
|
||||||
|
el.value = source.value;
|
||||||
|
const field = el.closest('.ge-opt');
|
||||||
|
const out = field?.querySelector('[data-opt-value]');
|
||||||
|
if (out) out.textContent = valueLabelOf(source, source.value);
|
||||||
|
});
|
||||||
|
// Selection mode buttons.
|
||||||
|
bar.querySelectorAll('[data-opt-mode]').forEach(btn => {
|
||||||
|
const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`);
|
||||||
|
btn.classList.toggle('active', !!source && source.classList.contains('active'));
|
||||||
|
});
|
||||||
|
// Move-tool auto-select (owned here rather than in the right panel).
|
||||||
|
const autoSelect = bar.querySelector('#ge-auto-select-layer');
|
||||||
|
if (autoSelect) autoSelect.checked = !!state.autoSelectLayer;
|
||||||
|
const target = bar.querySelector('#ge-auto-select-target');
|
||||||
|
if (target) target.value = state.autoSelectTarget === 'group' ? 'group' : 'layer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the controls for `toolId` and wire delegation + mirroring. */
|
||||||
|
export function renderOptionsBar(bar, toolId) {
|
||||||
|
if (!bar) return;
|
||||||
|
bar.dataset.optionsTool = toolId;
|
||||||
|
const hasControls = !!(BRUSH_TOOLS[toolId] || SELECTION_TOOLS[toolId] || LIQUIFY_TOOLS[toolId] || toolId === 'gradient' || toolId === 'move');
|
||||||
|
// Keep the strip mounted at a stable height for every tool. Hiding it
|
||||||
|
// for tools without options reflows the centered canvas-area and makes
|
||||||
|
// the canvas jump vertically when switching tools; an empty strip is
|
||||||
|
// visually harmless.
|
||||||
|
bar.classList.toggle('ge-options-empty', !hasControls);
|
||||||
|
|
||||||
|
// Brush-family tools now expose their secondary rows (spacing / smoothing /
|
||||||
|
// blend / pen pressure) through their own caret dropdown in the strip, so
|
||||||
|
// the generic "..." overflow button is retired for every tool.
|
||||||
|
const moreBtn = bar.querySelector('#ge-options-more');
|
||||||
|
if (moreBtn) {
|
||||||
|
const showMore = false;
|
||||||
|
moreBtn.hidden = !showMore;
|
||||||
|
if (!showMore) {
|
||||||
|
bar.classList.remove('ge-more-open');
|
||||||
|
moreBtn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = bar.querySelector('.ge-options-tool-icon');
|
||||||
|
const name = bar.querySelector('.ge-options-tool-name');
|
||||||
|
const meta = TOOLS[toolId];
|
||||||
|
if (name) name.textContent = meta?.label || toolId || '';
|
||||||
|
if (icon) icon.innerHTML = meta?.icon || '';
|
||||||
|
|
||||||
|
const body = bar.querySelector('.ge-options-body');
|
||||||
|
if (body) body.innerHTML = buildToolControls(toolId);
|
||||||
|
|
||||||
|
// Mirror right-panel changes back into the bar.
|
||||||
|
bar.querySelectorAll('[data-src]').forEach(el => {
|
||||||
|
const source = resolve(el.dataset.src);
|
||||||
|
if (!source || source.dataset.geOptMirror) return;
|
||||||
|
source.dataset.geOptMirror = '1';
|
||||||
|
source.addEventListener('input', () => syncOptionsBar(bar));
|
||||||
|
source.addEventListener('change', () => syncOptionsBar(bar));
|
||||||
|
});
|
||||||
|
|
||||||
|
syncOptionsBar(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the (empty) options-bar shell. Call `renderOptionsBar` after
|
||||||
|
* the right-panel controls exist so delegation can resolve them.
|
||||||
|
*/
|
||||||
|
export function buildOptionsBar() {
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'ge-options-bar';
|
||||||
|
bar.id = 'ge-options-bar';
|
||||||
|
bar.innerHTML = `
|
||||||
|
<div class="ge-options-tool">
|
||||||
|
<span class="ge-options-tool-icon" aria-hidden="true"></span>
|
||||||
|
<span class="ge-options-tool-name"></span>
|
||||||
|
</div>
|
||||||
|
<div class="ge-options-sep" aria-hidden="true"></div>
|
||||||
|
<div class="ge-options-body"></div>
|
||||||
|
<button type="button" class="ge-options-more" id="ge-options-more" title="More options" aria-label="More options" aria-haspopup="true" aria-expanded="false" hidden>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
const more = bar.querySelector('#ge-options-more');
|
||||||
|
more.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const open = bar.classList.toggle('ge-more-open');
|
||||||
|
more.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delegation: ranges + selects write through to their real control.
|
||||||
|
bar.addEventListener('input', event => {
|
||||||
|
const el = event.target.closest('[data-opt-range]');
|
||||||
|
if (!el) return;
|
||||||
|
const source = resolve(el.dataset.src);
|
||||||
|
if (!source) return;
|
||||||
|
source.value = el.value;
|
||||||
|
source.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
const out = el.closest('.ge-opt')?.querySelector('[data-opt-value]');
|
||||||
|
if (out) out.textContent = valueLabelOf(source, source.value);
|
||||||
|
});
|
||||||
|
bar.addEventListener('change', event => {
|
||||||
|
if (event.target.id === 'ge-auto-select-layer') {
|
||||||
|
state.autoSelectLayer = event.target.checked;
|
||||||
|
bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.id === 'ge-auto-select-target') {
|
||||||
|
state.autoSelectTarget = event.target.value === 'group' ? 'group' : 'layer';
|
||||||
|
bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = event.target.closest('[data-opt-select]');
|
||||||
|
if (!el) return;
|
||||||
|
const source = resolve(el.dataset.src);
|
||||||
|
if (!source) return;
|
||||||
|
source.value = el.value;
|
||||||
|
source.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
bar.addEventListener('click', event => {
|
||||||
|
const btn = event.target.closest('[data-opt-mode]');
|
||||||
|
if (!btn) return;
|
||||||
|
const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`);
|
||||||
|
source?.click();
|
||||||
|
syncOptionsBar(bar);
|
||||||
|
});
|
||||||
|
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ export function shortcutsPopupHTML() {
|
|||||||
<div><kbd>W</kbd> Wand</div>
|
<div><kbd>W</kbd> Wand</div>
|
||||||
<div><kbd>${TOOL_SHORTCUTS.marquee}</kbd> Marquee</div>
|
<div><kbd>${TOOL_SHORTCUTS.marquee}</kbd> Marquee</div>
|
||||||
<div><kbd>C</kbd> Crop</div>
|
<div><kbd>C</kbd> Crop</div>
|
||||||
|
<div><kbd>${TOOL_SHORTCUTS.gradient}</kbd> Gradient</div>
|
||||||
|
<div><kbd>${TOOL_SHORTCUTS.zoom}</kbd> Zoom <span style="opacity:0.5">(Alt-click = zoom out)</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-shortcuts-col">
|
<div class="ge-shortcuts-col">
|
||||||
<h5>Edit</h5>
|
<h5>Edit</h5>
|
||||||
@@ -62,6 +64,13 @@ export function shortcutsPopupHTML() {
|
|||||||
<div><kbd>]</kbd> Brush size +</div>
|
<div><kbd>]</kbd> Brush size +</div>
|
||||||
<div>Drag tolerance slider → live wand retune</div>
|
<div>Drag tolerance slider → live wand retune</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ge-shortcuts-col">
|
||||||
|
<h5>Touch</h5>
|
||||||
|
<div>Pinch → Zoom</div>
|
||||||
|
<div>Two-finger drag → Pan</div>
|
||||||
|
<div>Two-finger tap → Undo</div>
|
||||||
|
<div>Three-finger tap → Redo</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:8px;font-size:10px;opacity:0.5;text-align:center;">Press <kbd>?</kbd> or click the keyboard icon to toggle.</div>
|
<div style="margin-top:8px;font-size:10px;opacity:0.5;text-align:center;">Press <kbd>?</kbd> or click the keyboard icon to toggle.</div>
|
||||||
`;
|
`;
|
||||||
@@ -81,6 +90,7 @@ export function historyPanelHTML(historyIcon) {
|
|||||||
<button class="ge-adj-min" type="button" title="Minimise">−</button>
|
<button class="ge-adj-min" type="button" title="Minimise">−</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ge-history-snapshots" id="ge-history-snapshots"></div>
|
||||||
<div class="ge-history-list" id="ge-history-list"></div>
|
<div class="ge-history-list" id="ge-history-list"></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -95,6 +105,7 @@ export function canvasSizePromptHTML() {
|
|||||||
<div class="modal-content ge-canvas-prompt">
|
<div class="modal-content ge-canvas-prompt">
|
||||||
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New project</h4></div>
|
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New project</h4></div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<div class="ge-canvas-preview" hidden><img id="ge-canvas-prompt-preview" alt="Image preview" /></div>
|
||||||
<div class="ge-canvas-prompt-section-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 3v18"></path></svg><span>Resolution templates</span></div>
|
<div class="ge-canvas-prompt-section-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 3v18"></path></svg><span>Resolution templates</span></div>
|
||||||
<div class="ge-canvas-preset-grid" role="group" aria-label="Resolution templates">
|
<div class="ge-canvas-preset-grid" role="group" aria-label="Resolution templates">
|
||||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="1024x1024"><svg class="ge-canvas-preset-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="1"></rect></svg><span>Square</span><strong>1024 × 1024</strong></button>
|
<button type="button" class="ge-canvas-preset" data-canvas-preset="1024x1024"><svg class="ge-canvas-preset-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="1"></rect></svg><span>Square</span><strong>1024 × 1024</strong></button>
|
||||||
@@ -126,6 +137,7 @@ export function canvasSizePromptHTML() {
|
|||||||
<div class="ge-canvas-resize-options">
|
<div class="ge-canvas-resize-options">
|
||||||
<label class="ge-canvas-lock-option"><input type="checkbox" id="ge-canvas-prompt-lock" /> <span>Keep proportions</span></label>
|
<label class="ge-canvas-lock-option"><input type="checkbox" id="ge-canvas-prompt-lock" /> <span>Keep proportions</span></label>
|
||||||
<label class="ge-canvas-interpolation-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18 9 13l3 3 8-8"></path><path d="M15 8h5v5"></path></svg><span>Interpolation</span></span><select id="ge-canvas-prompt-interpolation"><option value="high">Smooth</option><option value="medium">Balanced</option><option value="low">Crisp</option></select></label>
|
<label class="ge-canvas-interpolation-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18 9 13l3 3 8-8"></path><path d="M15 8h5v5"></path></svg><span>Interpolation</span></span><select id="ge-canvas-prompt-interpolation"><option value="high">Smooth</option><option value="medium">Balanced</option><option value="low">Crisp</option></select></label>
|
||||||
|
<label class="ge-canvas-resolution-field" hidden><span class="ge-canvas-control-label"><span>Resolution</span></span><input type="number" id="ge-canvas-prompt-resolution" min="1" max="1000" value="72" /><span class="ge-canvas-resolution-unit">px/inch</span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer ge-canvas-prompt-footer">
|
<div class="modal-footer ge-canvas-prompt-footer">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user