Compare commits

...
Author SHA1 Message Date
dependabot[bot] f8e10d3051 chore(deps): bump the actions group across 1 directory with 13 updates
Bumps the actions group with 13 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-python](https://github.com/actions/setup-python) | `7.0.0` | `7.1.0` |
| [actions/setup-node](https://github.com/actions/setup-node) | `7.0.0` | `7.1.0` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `10.2.0` | `10.3.3` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.37.7` | `4.39.1` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.37.7` | `4.39.1` |
| [hadolint/hadolint-action](https://github.com/hadolint/hadolint-action) | `3.4.0` | `3.5.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.3.0` | `4.4.1` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.3.0` | `7.4.0` |
| [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.37.7` | `4.39.1` |
| [actions/deploy-pages](https://github.com/actions/deploy-pages) | `5.0.0` | `5.0.1` |
| [docker/login-action](https://github.com/docker/login-action) | `4.6.0` | `4.7.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` |



Updates `actions/setup-python` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/5fda3b95a4ea91299a34e894583c3862153e4b97...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0)

Updates `actions/setup-node` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

Updates `astral-sh/setup-uv` from 10.2.0 to 10.3.3
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/c18668ad3cf93ea998bef934396af7bb5c839dc7...68559e99cc807c1eb7e9238fd43a15bba460eec2)

Updates `github/codeql-action/init` from 4.37.7 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.37.7 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `hadolint/hadolint-action` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/hadolint/hadolint-action/releases)
- [Commits](https://github.com/hadolint/hadolint-action/compare/2a66e89f53d0771bb131a7fa31f3136336094aa6...06be81baf89a55ffd0e24b8f04a4185738dd3387)

Updates `docker/setup-buildx-action` from 4.3.0 to 4.4.1
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/37fe631027851001ddb9b187196cc803df7f5f0e...f87e5991a6d7451dcb8d9637bfbc97413f497069)

Updates `docker/build-push-action` from 7.3.0 to 7.4.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/53b7df96c91f9c12dcc8a07bcb9ccacbed38856a...c3c9e263c25d99ce0380d002d59b67737d91b0dc)

Updates `github/codeql-action/upload-sarif` from 4.37.7 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `actions/deploy-pages` from 5.0.0 to 5.0.1
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/cd2ce8fcbc39b97be8ca5fce6e763baed58fa128...368f82528645a54fb793d4d04e342629a3f51346)

Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

Updates `actions/upload-artifact` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

Updates `actions/download-artifact` from 8.0.1 to 8.0.2
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.3.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: hadolint/hadolint-action
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-02 23:16:11 +00:00
Alexandre Teixeira 2790708b19 feat(benchmarks): add offline evidence integrity auditor (#7030)
* feat(benchmarks): add offline C5 evidence auditor

* fix(benchmarks): prevent sensitive data in audit logs

* fix(benchmarks): prevent Markdown report injection

* fix(benchmarks): prevent Markdown inline injection

* fix(benchmarks): secure GFM report rendering

* ci(benchmarks): gate rendered GFM security regressions
2026-11-02 23:07:56 +00:00
4126bea113 fix(gallery): drop the second scrollbar on the photo detail view (#6597)
* fix(gallery): drop the second scrollbar on the photo detail view

With a photo open on desktop, the modal body, the images container and the
detail view are each height: 100%, so the container fills the body exactly.
The container's inline 2px top margin (grid-view spacing) then overflows the
body by 2px, and the body shows its own scrollbar next to the inspector's.
Zero that margin while the detail view is open.

Bumps the shared stylesheet/app cache token and the service worker cache
name, since a stylesheet changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(static): leave admin.js's cache tag unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cache): keep upstream's service worker cache name format

dev's editor tests require CACHE_NAME to match
odysseus-v<N>-pr40-editor-precache, so keep the version bump and use
that suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-02 15:44:29 +00:00
DracieVajkoandMatej f7f6abf82b feat(settings): add a tool approval mode with an explicit off (YOLO) option (#6304)
* feat: add optional tool approval mode

Admin-configurable tool_approval_mode setting (ask/auto) for the
post-external-context tool approval gate.

- ask (default): existing approval flow, gate stays on
- auto: bypasses the approval gate for all runs - same effect as
  ODYSSEUS_TOOL_APPROVAL_GATE=0. Documented honestly in the settings
  row copy and in THREAT_MODEL.md, per review on #6304.
- value validated server-side ({ask, auto}, anything else -> 400)
- no new env var: the setting wires to the existing gate control
- tests: setting validation + _tool_approval_gate_bypassed behavior

* feat: surface deployment gate state on Tool approval row

Follow-up to StressTestor's re-review: when ODYSSEUS_TOOL_APPROVAL_GATE is
set to a falsy value, the Tool approval row still read 'Always ask' even though
the gate was off, misleading admins the other way.

GET /api/auth/settings now returns tool_approval_gate_env_disabled (derived
from TOOL_APPROVAL_GATE_ENABLED) for admins, and the settings row renders a
red notice naming the env var and explaining that the gate is off for every
run regardless of the stored setting. Non-admin/scrubbed responses do not
expose the deployment state. Test covers both env values and the scrub path.

* refactor: rename tool approval mode auto to off

The gate-off value is now "off" ("Off (YOLO)" in the UI) so the auto-approve name stays available for the future judged mode.

---------

Co-authored-by: Matej <matej@local>
2026-11-01 12:22:56 -07:00
Alexandre Teixeira 2a413814c5 fix(chat): honor effective RAG policy in context building (#7025) 2026-11-01 16:14:51 +00:00
Tarık KöşkerandAlexandre Teixeira 1de3c72140 fix(effects): open the effect journal in binary mode on Windows (#6811)
Raw os.open() is text-mode on Windows unless O_BINARY is set, so each
\n written by the journal became \r\n. The log tracks byte offsets,
so the next append read the extra byte as a corrupt tail and every tool
call after the first in a turn failed with 'Effect claim could not be
persisted durably'.

OR O_BINARY (0 on POSIX) into the journal and launch-reservation opens.

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 16:12:42 +00:00
Yu-Wei ChangandAlexandre Teixeira 8450793fe6 feat(admin): rename model endpoints inline (#6970)
* feat(admin): rename model endpoints inline

* fix(cookbook): keep renamed endpoint names on relaunch

Relaunching a model at an existing endpoint URL reset the endpoint name
to the launched repo's short name, undoing a rename made in Added Models.
Only replace the name when it is still a name the Cookbook generates for
one of the endpoint's pinned models, so endpoints that were never renamed
still follow the newly served model.

---------

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 08:45:11 +00:00
c4fdd7ad5b tests: skip the searxng settings migration tests on Windows (#6882)
scripts/migrate_searxng_settings.py runs inside the searxng container
entrypoint and preserves the settings file's owner and mode with
os.fchmod/os.fchown, which do not exist on Windows, so every subprocess
run of the script in this module fails with AttributeError before the
behaviour under test is reached. Mark the module POSIX-only.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 08:33:08 +00:00
JulianFriedleandAlexandre Teixeira 6fced41220 feat(mcp): let a deployment declare local MCP tools (#6330)
* feat(mcp): let a deployment declare local MCP tools

Tools served by a user-configured MCP server are absent from
TOOL_CAPABILITIES, so they are unknown and fail high. For a server with
both a read and a write tool that is unusable: the read result arms the
post-external gate, because an unknown result is not SYSTEM integrity,
and the write that follows is refused until separately authorized. Every
second call needs an approval, for a server that never leaves the host.

Add ODYSSEUS_LOCAL_MCP_READ_TOOLS and ODYSSEUS_LOCAL_MCP_WRITE_TOOLS.
Declared read tools become READ_PRIVATE with SYSTEM integrity, so their
output no longer arms the gate; declared write tools become WRITE_PRIVATE
and stay inside POST_EXTERNAL_BLOCKED_EFFECTS, so a run tainted by real
external content still requires the approval. Unset variables keep the
current behaviour exactly.

Fixes #6329

* fix(mcp): secure local MCP authorization and preserve external taint gate

Keep declared local MCP read tool results untrusted with EXTERNAL_UNTRUSTED
integrity so they continue to arm the external-context gate. Introduce a
narrowly scoped continuation exception for declared local MCP writes on
the same server when no external taint or explicit error payloads enter
the run. Preserves gate protection for shell execution, network egress,
email, and cross-server actions.

Fixes #6329

* fix(mcp): resolve local continuation against stdio transport and enforce built-in classifications

Resolve declared local MCP continuation operations against connected stdio
servers and enforce established built-in tool security classifications.
Ensure remote HTTP and SSE servers, unverified transports, replaced sessions,
stale configurations, and operations with dangerous effects or missing request
grants fail closed.

* fix(mcp): require sealed authority for local continuation

* fix(mcp): seal local tool admission and synchronize GPU stacks

---------

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 03:19:59 +00:00
Yu-Wei ChangandAlexandre Teixeira 6f2b58c896 fix(cookbook): preserve endpoints across model launches (#6969)
* fix(cookbook): match stale endpoints by launched model, not display name

After registering an LLM endpoint, the Cookbook deleted every other local-*
endpoint with the same display name at a different URL. The display name is
the repo short name, which repos from different orgs share (for example
meta-llama/Llama-3.1-8B-Instruct and unsloth/Llama-3.1-8B-Instruct), so
launching one deleted the endpoint of the other while its server was running.

Match stale endpoints by the full model id they were last launched for,
which is the last entry of their pinned_models. Reusing an endpoint now
moves the launched model to the end of its pins so that entry stays current.

* fix(cookbook): guard endpoint cleanup by serve ownership

Preserve independently launched models instead of sweeping endpoints by display name or mutable model pins.

Bind Cookbook endpoint registrations to their serve sessions and prevent stale watchdogs or scheduled stops from deleting newer registrations.

Reuse the existing endpoint deletion contract to preserve settings, preferences, session cleanup, and cache invalidation.

Cover LLM and image endpoint lifecycle regressions.

---------

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 03:06:13 +00:00
39303f19cd fix(editor): stabilize image generation, layer editing and controls (#6992)
* fix(editor): stabilize image workflows and editing controls

* docs(editor): add desktop and mobile visual validation evidence

* fix(editor): synchronize quick edit model events and register imagePreferences in test coordinator

* fix(settings): enforce authoritative global image-gen policy and non-admin preference persistence

- Enforce authoritative administrator global image_gen_enabled disable in get_user_setting, do_generate_image, and image_editing_provider_available, preventing non-admin user preference overrides while preserving user-disabled opt-outs and can_generate_images privilege checks across direct, gallery, and editor paths.
- Filter non-admin preference persistence in saveImagePreferences to user preference keys only (image_model, image_edit_model, image_quality), preventing global policy toggles from leaking into user preferences.
- Return accurate 200 OK Response from saveImagePreferences after fallback persistence succeeds so settings UI displays 'Saved' instead of false 403 error, while retaining visibility of real errors.
- Load per-user preferences via readImagePreferences in initImageSettings so non-admin selections persist and display correctly after page reload.
- Clear per-user model preferences on successful admin save (POST /api/auth/settings) via PUT /api/prefs/{key} with value: null to prevent stale per-user preferences from shadowing updated global settings.
- Support clearing keys in PUT /api/prefs/{key} when value is null.
- Add comprehensive Python regression tests for global-disabled/user-enabled, global-enabled/user-disabled, and authorized generation, plus Node and browser regression tests for non-admin persistence, toggle isolation, reload, and admin clearing.

* fix(settings): invalidate shared appConfig cache near auth settings write

* fix(test): isolate test_tool_policy unit suite from external model endpoint state

* fix(test): isolate web_fetch failure regression from contextual follow-up heuristics

* fix(test): isolate test_tool_policy from cross-test approval gate state

Ensure _patch_loop_basics disables TOOL_APPROVAL_GATE_ENABLED so routing tests in test_tool_policy run in the intended opt-out posture even when prior tests populate DATA_DIR with skills or untrusted context. Remove unnecessary forced_tools and contextual web mock workarounds.

* fix(settings): isolate non-admin image toggle and surface preference cleanup errors

- Restrict image generation availability toggle to admin users in imageModels.js, disabling the switch for non-admins and reverting unauthorized changes with an explicit message.
- Separate non-admin image model preference saves from global toggle updates.
- Track preference cleanup results on admin save in imagePreferences.js, propagating cleanup failures instead of ignoring them.
- Require valid preference keys on 403 fallback so toggle-only attempts reject cleanly.
- Add /api/prefs route mock to test_image_model_settings_sync_both_directions_and_survive_reopening.
- Add regression coverage for admin cleanup failures and non-admin toggle rejection.

* fix(settings): reconcile admin toggle on partial cleanup failure and report errors

- In imagePreferences.js, distinguish pre-persistence failures from post-save cleanup failures using err.globalSaved and err.phase.

- In imagePreferences.js, notify listeners of global policy updates on partial success before propagating cleanup failure.

- In imageModels.js, retain the persisted target state on admin toggle when global persistence succeeds but cleanup fails.

- In imageModels.js, reconcile admin toggle with authoritative server state via readImagePreferences() after ambiguous or partial failures.

- In imageModels.js, report partial success accurately without suppressing preference cleanup error messages.

- Add regression coverage in test_settings_save_response_ok_js.py for cleanup HTTP 500, cleanup network rejection, successful cleanup, and failed global POST.

---------

Co-authored-by: pewdiepie-archdaemon <pewdiepie-archdaemon@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 02:56:46 +00:00
5353241743 fix(chat): warn before regenerate, resend or edit deletes later messages; offer fork (#6225)
* fix(chat): warn before "regenerate" permanently deletes later messages

Both "regenerate" entry points — the per-message footer's ↻ "Regenerate
from here" button, and the vision editor's "Regenerate message" button —
call POST /api/session/{id}/truncate, which does a real, permanent SQL
delete of every message after the point clicked, then generates a new
reply. Clicking either on anything but the very last exchange did this with
zero warning: the button's own label doesn't say it also erases the rest
of the conversation, and neither button visually signals it's destructive
differently from any other message-footer action.

Caught live: a mis-click destroyed a real conversation with no way to
recover it (no backup covered the affected window). This adds a
confirmation prompt — via the existing uiModule.styledConfirm — whenever
the action would delete more than just the message(s) being regenerated,
stating the exact count of messages that would be lost. Regenerating the
most recent exchange (the everyday case, nothing exists after it) is
unaffected and still proceeds with zero friction.

New test: tests/test_regenerate_truncate_warning_js.py — drives the real
regenerateFrom/resendUserMessage functions under Node with a fake message
list DOM, confirming: the last-exchange case never prompts and truncates
directly; an earlier case prompts with the correct count; cancelling
aborts before any network call; confirming proceeds with the correct
keep_count. Also fixed a pre-existing, unrelated gap this surfaced while
verifying the full suite: test_pr6020_browser_review_regressions.py's
Node harness never stubbed `navigator` at all (sessions.js reads
navigator.platform at module load) — a gap on origin/dev itself, not
caused by this branch's diff, using the same Object.defineProperty fix
already applied on #6019/#6148 for the sibling issue there (Node 21+'s own
getter-only navigator global, not relevant to this specific failure but
the same defensive pattern).

Verified: full pytest suite in-container, 5798 passed / 11 pre-existing
unrelated failures (README/docs/agent-loop baseline) / 0 new.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(chat): cover plain resend after dev's replace-by-default change

dev made plain resend (no options) replace from the clicked turn by
default, with append-only resend opt-in via { append: true } (see
test_resend_message_nondestructive.py). The footer "Resend message"
button calls resendUserMessage(el) with no options, so it now truncates
everything after the clicked turn and goes through this PR's warning.
Replace the old "plain resend never prompts" test, which assumed the
previous append-by-default behavior, with one asserting that plain
resend on an earlier pair prompts with the right count, and one
asserting that explicit append-only resend never truncates or prompts.

Also resolve ui.js/sessions.js through the same cache-busting query
string chat.js imports them with. ES modules cache by full URL, so the
harness's unversioned import of ui.js was a second module instance and
its uiMod.styledConfirm stub never reached the instance chat.js calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): warn before an edit deletes later messages; offer to fork instead

Sending an edited user message truncates the chat at that message, the
same unrecoverable server-side delete as regenerate and resend, but did
it with no prompt. It now goes through the same confirmation, worded for
the action ("Sending this edit will also permanently delete the N
messages after it"). Cancelling leaves the editor open with the draft.

The confirmation for all three actions (regenerate, resend, edit) also
offers "Fork from here": it forks the chat just before the user turn,
switches to the new chat, and replays the action there (the same message,
or the edited text), leaving the original chat untouched. forkFrom now
shares the same fork helper.

Tests drive the real editUserMessage Send handler under Node and cover
the edit prompt (count, cancel, last-message no-prompt) and the fork
choice for each action (fork keep_count, no truncate, replayed text).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): cut and fork a chat by message id, not loaded-page position

Regenerate and edit sent keep_count, the clicked message's position among
the messages loaded in the page. A long chat loads only its newest messages
until the user scrolls up, so that position is smaller than the real one and
the truncate deleted real messages above the clicked one (regenerating the
latest reply in a ~50-message chat removed about a dozen earlier messages).
Resend already sent before_msg_id; regenerate and edit now do the same.

The fork options this PR adds had the same flaw. Fork now accepts
before_msg_id (fork before that message) and through_msg_id (include it),
resolved against the stored messages; keep_count stays as the fallback for a
message that has no id yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): keep a regenerated turn's message id current; stop on a refused cut

Regenerate keeps the original user bubble on screen and resubmits its text
without drawing a new bubble. The resubmitted message is a new DB row, but
the bubble kept the deleted row's id, so the next regenerate's cut was
refused (404) and the page sent the turn anyway, stacking a second reply.

The stream now reports the saved user message's id (user_message_saved),
which the page attaches to the user bubble, or to the bubble a regenerate
kept. Regenerate and edit stop with an error when the server refuses the
cut instead of sending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(chat): name the buttons in words instead of symbols

CONTRIBUTING asks for no Unicode emoji in UI or code; the test comments
used the regenerate and pencil glyphs to name buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(chat): share the fork-and-send step across edit, resend and regenerate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): pass the regenerate fork its attachment ids explicitly

Resend already hands its collected file ids to _forkAndSend; regenerate
relied on the earlier _pendingRegenAttachments assignment. Pass _regenIds
too so both fork paths read the same, and check the hand-off in the
regenerate tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(chat): assert fork choices keep the user message's attachments

Run regenerate and resend's "Fork from here" against a user message with
an uploaded file and check that the send fired in the fork carries its
file id, replacing the source-text check. chat.js exposes a read-only
_pendingRegenAttachmentIds() for this, like cookbook-hwfit.js's test hooks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): preserve attachments across edit and regeneration

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 02:47:32 +00:00
Wojciech TaisnerandAlexandre Teixeira 285d05e72e fix(docker): forward ODYSSEUS_FIRST_TOKEN_TIMEOUT through compose (#6778)
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 02:28:58 +00:00
dc0fbbf7aa fix(compose): default ODYSSEUS_TTS_CACHE_MAX_BYTES so compose stops warning (#6619) (#6645)
The three shipped compose files forwarded the variable bare, so every
`docker compose` command on a default .env printed "variable is not set".
Default it to the 500 MiB that .env.example documents, like the other
limits, and add a guard test that rejects any bare ${VAR} in compose files.

Fixes #6619

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 02:03:06 +00:00
zai184andAlexandre Teixeira 12b9c5f2ad chore(compose): parameterize searxng host port via SEARXNG_HOST_PORT (#6598)
The searxng host binding was hardcoded to 127.0.0.1:8080 in all three
compose files, which collides with other stacks that legitimately use
8080 (e.g. a local llama.cpp server). Parameterize it the same way the
odysseus UI port already works:

  ports:
    - "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"

Default unchanged (8080). The UI->searxng connection uses the container
port over the docker network (SEARXNG_INSTANCE=http://searxng:8080), so
no other change is needed.

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 01:55:36 +00:00
cb26ccd9a7 test: skip native bash tool tests on Windows (2 modules) (#6872)
* tests: skip native bash behavior test on Windows in test_production_external_bridge

On Windows the only containment mechanism (windows_tree) enforces wall-clock
only; ENFORCING mode refuses bash (filesystem/process_tree unavailable), so
exit_code is 1. The test asserts bubblewrap/process_group: POSIX-only by design.

Part of #4287

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tests: skip native bash exit-status test on Windows in test_preview_execution_evidence

BashTool needs enforcing containment (filesystem/process_tree); Windows
windows_tree gives wall-clock only, so the command is not executed and
exit_code is 1 instead of 7. POSIX-only by design.

Part of #4287

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 01:47:57 +00:00
23a480571a tests: skip the 0o600 file-mode assert on Windows in the effect log fsync test (#6892)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 01:39:56 +00:00
6a43cf23ea tests: make hardcoded /tmp assertions OS-neutral (#6864)
Root cause: _is_sensitive_path splits on os.sep, so POSIX literals missed
on Windows; /tmp is not a root on Windows (only $TMPDIR); inline-JSON test
compared against "/tmp/wf.txt" while Windows abspath is C:\tmp\wf.txt.
Change: native-separator helper, TMPDIR monkeypatch, abspath alternative.
Before: 9 failed; after: 0 failed (74 passed, 1 symlink skip).

Part of #4287

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 01:33:28 +00:00
64b457dc0a fix(tests): read static sources as UTF-8 in the static assertion tests (#6850)
On native Windows the default text encoding is the ANSI code page
(cp1252 on en/pt systems), so the module-level read_text() calls in
eight static tests fail at collection with UnicodeDecodeError on bytes
0x81/0x90 that appear in static/js/chat.js, chatRenderer.js, app.js,
index.html and src/visual_report.py. Pass encoding="utf-8" like the
other 700+ tests in the suite already do.

Found by running the suite natively on Windows 11 (Python 3.11).

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 01:26:11 +00:00
red person ef3467e8d4 fix(tasks): render daily brief calendars in local time (#6926) 2026-10-31 13:54:14 +00:00
red person 72e8520fa3 fix(models): honor visible pinned IDs in route selection (#6922) 2026-10-31 13:51:59 +00:00
red person 9276db9c11 fix(models): skip non-chat models when resolving chat defaults (#6907) 2026-10-31 13:49:26 +00:00
GrzegorzandClaude Opus 5.5 a797cee0b3 fix(cookbook): diagnose vLLM serves on an NVIDIA driver that is too old (#6727) (#6728)
- Add a diagnosis for "NVIDIA driver on your system is too old" and
  "CUDA driver version is insufficient for CUDA runtime version", the
  errors PyPI's CUDA 13 torch/vLLM builds hit on drivers older than 580.
- Remove the nested _diagnose_serve_output copy in cookbook_routes.py.
  It shadowed the helper and had drifted, so /api/cookbook/tasks/status
  never used the helper's newer diagnoses.
- Capture the tmux pane with -J in the status poll so lines wrapped at
  the pane width do not split error text mid-word.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 13:47:30 +00:00
Grzegorz 76061498c8 fix(gallery): validate album rename and image_ids input (#6822) (#6823) 2026-10-31 13:45:02 +00:00
Merajul Arefin 04f8546d9d fix(search): gallery library per-word search with LIKE escaping (#6584)
Gallery search built a single `%{search}%` LIKE, so multi-word queries only matched the exact adjacent phrase ("red car" missed "a car that is red") and literal % / _ acted as SQL wildcards. Split on whitespace and require each token to match (prompt OR tags OR ai_tags), escaping LIKE wildcards per token; same escaping for the tag filter. Promote _escape_like to public escape_like_pattern() shared helper.

Part of #2123.

Tests: tests/test_gallery_search_multiword.py (5 tests); 4 fail pre-fix, 5 pass post-fix. Neighbors (session_search, gallery null-user, shims): 25 passed.
2026-10-31 13:43:20 +00:00
Robert Vind-Gardoș 9835c1a7f9 fix(skills): refuse renaming a skill onto a name the owner already uses (#6941)
update_skill only checked whether the target directory existed. A skill
with that name in another category lives elsewhere on disk, so the
rename went through and left two skills with one name. Update and
delete look skills up by name, so the next delete removed whichever
file os.walk reached first.

Check the owner's skills in every category before renaming, and answer
409 from PUT /api/skills/{id} instead of the misleading 404.
2026-10-31 13:34:05 +00:00
Isharakandisharak7m 239f09cbc4 fix(docker): retry PyPI fetches in realesrgan wheel builder (#6407)
Replace urllib-based PyPI metadata retrieval with curl --retry 8 to
handle transient TLS/SSL EOF errors that abort the Docker image build.
The sdist download also gets retry logic. No functional change to the
patching or wheel-building steps.

Co-authored-by: isharak7m <192635824+isharak7m@users.noreply.github.com>
2026-10-31 13:32:28 +00:00
GrzegorzandClaude Opus 5.5 a1a7f8fc9a fix(agent): don't clamp an unknown context window to the small-server budget (#6678) (#6706)
budget_context_for_model() returns 0 when the endpoint does not prove its
context window, and compute_input_token_budget() answers 0 with the
conservative default budget (6000). The agent loop's small-local-server
clamp (`candidate_context <= 8192`) also matched 0 and cut the request to
max(1200, 0) tokens, so on any endpoint with an unreported window an open
document or a few thousand tokens of earlier context were trimmed away
before the model saw them ("5519 tokens > 1200 budget (ctx=1800)").

Apply the clamp only to a known window.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 13:30:29 +00:00
Prince 87193d23f5 fix(agent-loop): release buffered prose when '<' is not a tool tag (#6757) (#6758) 2026-10-31 13:26:43 +00:00
VykosandClaude 516f046fef fix(chat): label the vision description injected for text-only models (#6223)
When the chat model cannot see images, the separate vision call's description
is appended to the user's message. Nothing marked it as machine-derived, so the
chat model read it as the user's own prose and answered the description instead
of the question.

The label goes below the `[Image: <name>]` marker line because the client
strips the block by matching that exact prefix; renaming the marker would leave
the raw description rendered in the user's bubble.

Fixes #5573

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-31 13:24:32 +00:00
red person 48c3a7fc36 fix(pdf): convert CMYK images before vision analysis (#6917) 2026-10-31 13:22:55 +00:00
red person 70fc58225c fix(documents): treat library search punctuation literally (#6944) 2026-10-31 13:21:10 +00:00
red person 9efedc8b07 fix(documents): retain language extensions in ZIP exports (#6848) 2026-10-31 13:18:58 +00:00
gloss-stack 1d251ef867 Fix transcript search snippet positioning (#6206) 2026-10-31 13:05:35 +00:00
Nguyen Thanh Dat bf5bf57bc1 fix(diagnostics): report a failed config source instead of "disabled" (#6154) (#6169)
`_gather_inputs()` fails soft: a source that raises leaves its inventory as
`[]`. Every probe reads an empty inventory as "not configured" and answers
`disabled`, so a broken integrations file, email store, or endpoint database is
indistinguishable from a subsystem the operator deliberately turned off. With
the remaining subsystems disabled, `GET /api/diagnostics/services` then reports
`overall: "ok"` while three of its inputs failed to load — the exceptions appear
only in DEBUG logs.

Record each failure alongside the empty value (`failed`: source -> controlled
category) and report the affected subsystem through `_source_unavailable()`
rather than probing it with an inventory that is empty for the wrong reason.

- `degraded`, not `disabled`: the configured state is unknown, not known-off.
  Not `down` either — nothing reached the subsystem and failed; the inventory
  could not be read. Severity is non-zero either way, so a report containing a
  failed source can no longer roll up to `ok`.
- A source that loads and is genuinely empty still reports `disabled`.
- One broken source degrades one entry; the others are still probed.
- `meta` carries the source name and the controlled `_classify_error()`
  category only, never exception text, credentials, or database detail.
- `inputs.get("failed")` so an inputs payload without the key still probes
  normally.

Verified with the reporter's harness: settings loading with
`search_provider: "disabled"` while the other three sources raise now yields
`overall: "degraded"` with `searxng` still `disabled`; before, all five read
`disabled` and `overall` was `ok`.
2026-10-31 13:04:18 +00:00
MBGAandBolt4243 2019a97339 fix(skills): preserve escaped quotes in frontmatter lists (#6265)
* fix(skills): preserve escaped quotes in frontmatter lists

* test(skills): use descriptive frontmatter examples

---------

Co-authored-by: Bolt4243 <277300782+Bolt4243@users.noreply.github.com>
2026-10-31 13:02:02 +00:00
Aashish Mahato 9a1fdb192e fix(cookbook): resolve NameError in hf-gguf-files exception handler (#6341) (#6369)
In routes/cookbook_routes.py, the exception handler for GET /api/cookbook/hf-gguf-files referenced repo instead of the parameter repo_id, causing an unhandled NameError and 500 status when upstream requests to Hugging Face failed.

Fixes #6341.
2026-10-31 12:59:17 +00:00
149 changed files with 13616 additions and 838 deletions
+11
View File
@@ -105,6 +105,17 @@ SEARXNG_INSTANCE=http://localhost:8080
# trusted single-user setups that accept injected content reaching side-effect tools. # trusted single-user setups that accept injected content reaching side-effect tools.
# ODYSSEUS_TOOL_APPROVAL_GATE=1 # ODYSSEUS_TOOL_APPROVAL_GATE=1
# Comma-separated list of exact qualified MCP read and write tool names
# on host-local servers. Only explicitly configured stdio MCP servers qualify.
# Declarations do not override built-in tool effects. Declared reads remain
# untrusted (EXTERNAL_UNTRUSTED) to prevent prompt injections from reaching shell
# or network tools, but allow continuation to declared non-destructive write tools
# on the exact same stdio server without repeated approvals. Stdio transport does
# not guarantee network isolation. Authorization must still originate from the
# trusted request. Automatic same-store writes carry residual prompt-injection risk.
# ODYSSEUS_LOCAL_MCP_READ_TOOLS=mcp__notes__search_notes,mcp__notes__read_note
# ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=mcp__notes__create_note,mcp__notes__update_note
# Mark session cookies Secure. Left unset, this follows the request scheme: # Mark session cookies Secure. Left unset, this follows the request scheme:
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to # an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
# force it on, or false to force it off while you still serve plain HTTP. # force it on, or false to force it off while you still serve plain HTTP.
+23 -5
View File
@@ -76,7 +76,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with: with:
python-version: "3.11" python-version: "3.11"
# Byte-compile sources — catches syntax errors without installing deps. # Byte-compile sources — catches syntax errors without installing deps.
@@ -91,7 +91,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with: with:
node-version: "22.23.4" node-version: "22.23.4"
- run: npm ci --ignore-scripts --no-audit --no-fund - run: npm ci --ignore-scripts --no-audit --no-fund
@@ -151,7 +151,7 @@ jobs:
echo "docs_only=false" >> "$GITHUB_OUTPUT" echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi fi
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
with: with:
python-version: "3.11" python-version: "3.11"
@@ -168,7 +168,7 @@ jobs:
# uv resolves the same versions as pip but installs ~5x faster, and its # uv resolves the same versions as pip but installs ~5x faster, and its
# cache keeps each shard from re-downloading the whole requirement set. # cache keeps each shard from re-downloading the whole requirement set.
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
with: with:
version: "0.13.5" version: "0.13.5"
@@ -179,8 +179,9 @@ jobs:
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
python scripts/check_ca_trust.py --python python scripts/check_ca_trust.py --python
uv pip install --system -r requirements.txt uv pip install --system -r requirements.txt
uv pip install --system "playwright==1.62.0"
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
with: with:
node-version: "22.23.4" node-version: "22.23.4"
@@ -263,3 +264,20 @@ jobs:
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \ ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
--config tests/e2e/ios-layout.config.cjs \ --config tests/e2e/ios-layout.config.cjs \
--reporter=line --reporter=line
benchmark-gfm-security:
name: Benchmark GFM rendering security
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.11"
# Test-only renderer, pinned independently of application requirements.
- name: Install GitHub Flavored Markdown renderer
run: |
python -m pip install --disable-pip-version-check --only-binary=:all: "cmarkgfm==2025.10.22"
- name: Verify rendered benchmark report security
run: python tests/test_audit_c5_benchmark_evidence.py --gfm
+2 -2
View File
@@ -31,11 +31,11 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: none build-mode: none
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with: with:
category: "/language:${{ matrix.language }}" category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Lint Dockerfile - name: Lint Dockerfile
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0 uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
with: with:
dockerfile: Dockerfile dockerfile: Dockerfile
# DL3008: pinning apt package versions is impractical on a -slim base # DL3008: pinning apt package versions is impractical on a -slim base
+5 -5
View File
@@ -61,14 +61,14 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Buildx - name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with: with:
driver: docker driver: docker
# Build without pushing so a broken Dockerfile is caught here, and the # Build without pushing so a broken Dockerfile is caught here, and the
# exact image we ship is what gets scanned. # exact image we ship is what gets scanned.
- name: Build image - name: Build image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with: with:
context: . context: .
push: false push: false
@@ -107,12 +107,12 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Buildx - name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with: with:
driver: docker driver: docker
- name: Build image - name: Build image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with: with:
context: . context: .
push: false push: false
@@ -133,7 +133,7 @@ jobs:
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
- name: Upload Trivy results - name: Upload Trivy results
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with: with:
sarif_file: trivy-results.sarif sarif_file: trivy-results.sarif
category: trivy-image category: trivy-image
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with: with:
python-version: '3.12' python-version: '3.12'
+1 -1
View File
@@ -47,4 +47,4 @@ jobs:
steps: steps:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+7 -7
View File
@@ -55,16 +55,16 @@ jobs:
shell: bash shell: bash
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- name: Set up Buildx - name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest - name: Build and push by digest
id: build id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with: with:
context: . context: .
pull: true pull: true
@@ -78,7 +78,7 @@ jobs:
digest="${{ steps.build.outputs.digest }}" digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}" touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest - name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: digest-${{ matrix.arch }} name: digest-${{ matrix.arch }}
path: /tmp/digests/* path: /tmp/digests/*
@@ -107,15 +107,15 @@ jobs:
echo "version=$v" >> "$GITHUB_OUTPUT" echo "version=$v" >> "$GITHUB_OUTPUT"
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Download digests - name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with: with:
path: /tmp/digests path: /tmp/digests
pattern: digest-* pattern: digest-*
merge-multiple: true merge-multiple: true
- name: Set up Buildx - name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
+2 -2
View File
@@ -62,9 +62,9 @@ jobs:
env: env:
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }} SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
run: docker pull "$SEARXNG_TEST_IMAGE" run: docker pull "$SEARXNG_TEST_IMAGE"
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Check unmodified upstream trust and actual entrypoint offline - name: Check unmodified upstream trust and actual entrypoint offline
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with: with:
context: . context: .
file: docker/searxng/Dockerfile file: docker/searxng/Dockerfile
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with: with:
python-version: '3.12' python-version: '3.12'
+3 -2
View File
@@ -64,12 +64,13 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
`src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately. `src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately.
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation. It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). The admin-only `tool_approval_mode` setting (Settings → Agent Tools, `off` = "Off (YOLO)") is the same knob exposed in the UI: with `off`, every run sets `approval_gate_bypassed` and this gate stops asking, exactly as if the env var were off. Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off — by either knob — lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
Two exemptions apply even when the gate is on, both deliberate: Three exemptions apply even when the gate is on, all deliberate:
- Sources in `_CONTROL_PLANE_CONTEXT_SOURCES` (skills, runtime descriptors, the open editor document, the open email, uploaded files) are treated as control-plane metadata and still permit read-only tools. - Sources in `_CONTROL_PLANE_CONTEXT_SOURCES` (skills, runtime descriptors, the open editor document, the open email, uploaded files) are treated as control-plane metadata and still permit read-only tools.
- A TUI run that advertises a host shell bridge and declares `unattended_mode` exempts the local execution set in `TUI_CLIENT_TOOL_NAMES`. Personal, network and deployment-local tools are never exempted. - A TUI run that advertises a host shell bridge and declares `unattended_mode` exempts the local execution set in `TUI_CLIENT_TOOL_NAMES`. Personal, network and deployment-local tools are never exempted.
- Narrow continuation for local MCP tools declared via `ODYSSEUS_LOCAL_MCP_READ_TOOLS` and `ODYSSEUS_LOCAL_MCP_WRITE_TOOLS`: Only explicitly configured stdio MCP servers qualify; remote HTTP and SSE servers fail closed. Declarations never override built-in tool effects, so built-in email, browser, memory, image generation, and other built-in servers cannot receive continuation exceptions. Declared read tools remain untrusted (`EXTERNAL_UNTRUSTED`) to ensure that prompt injections in local data cannot reach shell, network, or unrelated tools without approval. Stdio transport does not guarantee network isolation, as local processes may still reach the host network. However, declared non-destructive write tools on the exact same stdio MCP server are permitted to continue without an approval prompt if the only untrusted context in the run was produced by declared local reads on that same server, without any explicit error or external taint. Genuinely external content (web search, web fetch, emails, external message context) blocks these writes. Authorization must still originate from the trusted request: request authority must permit the tool and retain the sealed backend resource grant. The residual same-store write risk (the agent writing to the same local store after reading a local prompt injection) remains subject to the user's initial request authority.
## Security Headers ## Security Headers
+22
View File
@@ -568,6 +568,7 @@ class ModelEndpoint(TimestampMixin, Base):
hidden_models = Column(Text, nullable=True) # JSON list of model IDs that failed probing hidden_models = Column(Text, nullable=True) # JSON list of model IDs that failed probing
cached_models = Column(Text, nullable=True) # JSON list of last-known model IDs (avoids probe on list) cached_models = Column(Text, nullable=True) # JSON list of last-known model IDs (avoids probe on list)
pinned_models = Column(Text, nullable=True) # JSON list of admin-pinned model IDs (manual, may not appear in /v1/models) pinned_models = Column(Text, nullable=True) # JSON list of admin-pinned model IDs (manual, may not appear in /v1/models)
cookbook_serve_session_id = Column(String, nullable=True) # Current Cookbook serve identity
model_type = Column(String, nullable=True, default="llm") # "llm" or "image" model_type = Column(String, nullable=True, default="llm") # "llm" or "image"
# auto = classify by URL; local = self-hosted server; api/proxy = external # auto = classify by URL; local = self-hosted server; api/proxy = external
# OpenAI-compatible API even when reachable through a private/tailnet IP. # OpenAI-compatible API even when reachable through a private/tailnet IP.
@@ -1378,6 +1379,26 @@ def _migrate_add_pinned_models_column():
except Exception: except Exception:
pass pass
def _migrate_add_cookbook_serve_session_id_column():
"""Bind Cookbook cleanup to the serve session that owns the endpoint."""
from sqlalchemy import inspect
with engine.begin() as conn:
columns = {
column["name"]
for column in inspect(conn).get_columns("model_endpoints")
}
if "cookbook_serve_session_id" not in columns:
conn.execute(
text(
"ALTER TABLE model_endpoints "
"ADD COLUMN cookbook_serve_session_id VARCHAR"
)
)
def _migrate_add_notes_sort_order(): def _migrate_add_notes_sort_order():
"""Add sort_order, image_url, repeat columns to notes if they don't exist.""" """Add sort_order, image_url, repeat columns to notes if they don't exist."""
import sqlite3 import sqlite3
@@ -2390,6 +2411,7 @@ def init_db():
_migrate_add_hidden_models_column() _migrate_add_hidden_models_column()
_migrate_add_cached_models_column() _migrate_add_cached_models_column()
_migrate_add_pinned_models_column() _migrate_add_pinned_models_column()
_migrate_add_cookbook_serve_session_id_column()
_migrate_add_notes_sort_order() _migrate_add_notes_sort_order()
_migrate_add_model_type_column() _migrate_add_model_type_column()
_migrate_add_model_endpoint_refresh_columns() _migrate_add_model_endpoint_refresh_columns()
+6 -2
View File
@@ -73,6 +73,8 @@ services:
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0} - ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-} - ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} - ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760} - ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600} - ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
@@ -82,13 +84,15 @@ services:
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456} - ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES} - ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
# Host workspace translation is opt-in. Keep the public compose file # Host workspace translation is opt-in. Keep the public compose file
# user-neutral; configure these in a local .env or use the host-workspace # user-neutral; configure these in a local .env or use the host-workspace
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR. # overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-} - ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace} - ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-} - ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
@@ -165,7 +169,7 @@ services:
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true /usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh exec /usr/local/searxng/entrypoint.sh
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
volumes: volumes:
- searxng-data:/etc/searxng - searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z - ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
+6 -2
View File
@@ -72,6 +72,8 @@ services:
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0} - ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-} - ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} - ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760} - ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600} - ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
@@ -81,13 +83,15 @@ services:
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456} - ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES} - ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
# Host workspace translation is opt-in. Keep the public compose file # Host workspace translation is opt-in. Keep the public compose file
# user-neutral; configure these in a local .env or use the host-workspace # user-neutral; configure these in a local .env or use the host-workspace
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR. # overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-} - ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace} - ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-} - ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
@@ -168,7 +172,7 @@ services:
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true /usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh exec /usr/local/searxng/entrypoint.sh
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
volumes: volumes:
- searxng-data:/etc/searxng - searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z - ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
+6 -2
View File
@@ -61,6 +61,8 @@ services:
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0} - ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-} - ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} - ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760} - ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600} - ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
@@ -70,13 +72,15 @@ services:
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456} - ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES} - ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
# Host workspace translation is opt-in. Keep the public compose file # Host workspace translation is opt-in. Keep the public compose file
# user-neutral; configure these in a local .env or use the host-workspace # user-neutral; configure these in a local .env or use the host-workspace
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR. # overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-} - ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace} - ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-} - ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
@@ -146,7 +150,7 @@ services:
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true /usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh exec /usr/local/searxng/entrypoint.sh
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
volumes: volumes:
- searxng-data:/etc/searxng - searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z - ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
+32 -10
View File
@@ -31,21 +31,43 @@ for spec in $SPECS; do
name="${spec%%==*}" name="${spec%%==*}"
ver="${spec##*==}" ver="${spec##*==}"
# pip download builds metadata (and trips the same bug), so fetch the raw # pip download builds metadata (and trips the same bug), so fetch the raw
# sdist URL from the PyPI JSON API instead. # sdist URL from the PyPI JSON API instead. Use curl with retries to
url="$(python - "$name" "$ver" <<'PY' # survive transient TLS / network failures that urllib cannot recover from.
import json, sys, urllib.request metadata="${name}-${ver}.json"
name, ver = sys.argv[1], sys.argv[2] curl --fail --silent --show-error --location \
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json")) --retry 8 \
for f in data["urls"]: --retry-delay 3 \
if f["packagetype"] == "sdist": --retry-max-time 120 \
print(f["url"]); break --retry-all-errors \
"https://pypi.org/pypi/${name}/${ver}/json" \
-o "$metadata"
url="$(python - "$metadata" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as f:
data = json.load(f)
for item in data["urls"]:
if item["packagetype"] == "sdist":
print(item["url"])
break
else: else:
sys.exit(f"no sdist found for {name}=={ver}") raise SystemExit("no sdist found")
PY PY
)" )"
rm -f "$metadata"
echo ">> fetching ${name} ${ver}: ${url}" echo ">> fetching ${name} ${ver}: ${url}"
curl -fsSL "$url" -o "${name}.tar.gz" curl --fail --silent --show-error --location \
--retry 8 \
--retry-delay 3 \
--retry-max-time 120 \
--retry-all-errors \
"$url" \
-o "${name}.tar.gz"
tar xzf "${name}.tar.gz" tar xzf "${name}.tar.gz"
rm -f "${name}.tar.gz"
done done
echo ">> patching get_version()" echo ">> patching get_version()"
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+1
View File
@@ -2,3 +2,4 @@
-r requirements.txt -r requirements.txt
# psutil lets `-n auto` use physical cores instead of logical CPU threads. # psutil lets `-n auto` use physical cores instead of logical CPU threads.
pytest-xdist[psutil]>=3.8,<4 pytest-xdist[psutil]>=3.8,<4
playwright==1.62.0
+18
View File
@@ -18,6 +18,7 @@ from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, S
from src.auth_helpers import _auth_disabled from src.auth_helpers import _auth_disabled
from src.rate_limiter import RateLimiter from src.rate_limiter import RateLimiter
from src.settings_scrub import scrub_settings from src.settings_scrub import scrub_settings
from src import tool_capabilities
from src.settings import ( from src.settings import (
load_settings as _load_settings, load_settings as _load_settings,
save_settings as _save_settings, save_settings as _save_settings,
@@ -753,6 +754,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request) user = _get_current_user(request)
settings = without_retired_settings(_load_settings()) settings = without_retired_settings(_load_settings())
if _auth_disabled() or (user and auth_manager.is_admin(user)): if _auth_disabled() or (user and auth_manager.is_admin(user)):
# Deployment-level truth for the Tool approval row: a falsy
# ODYSSEUS_TOOL_APPROVAL_GATE disables the approval gate for every
# run regardless of the stored tool_approval_mode, so surface it to
# admins instead of letting the row mislead them the other way.
settings["tool_approval_gate_env_disabled"] = not tool_capabilities.TOOL_APPROVAL_GATE_ENABLED
return settings return settings
return scrub_settings(settings) return scrub_settings(settings)
@@ -769,6 +775,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"agent_max_tool_calls": (0, 1000), # 0 = unlimited "agent_max_tool_calls": (0, 1000), # 0 = unlimited
"auto_compact_threshold_percent": (50, 95), "auto_compact_threshold_percent": (50, 95),
} }
_VALID_VALUES = {
"tool_approval_mode": {"ask", "off"},
}
for key in DEFAULT_SETTINGS: for key in DEFAULT_SETTINGS:
if key in RETIRED_SETTING_KEYS: if key in RETIRED_SETTING_KEYS:
continue continue
@@ -782,6 +791,15 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
except (TypeError, ValueError): except (TypeError, ValueError):
raise HTTPException(400, f"{key} must be an integer") raise HTTPException(400, f"{key} must be an integer")
val = max(lo, min(val, hi)) val = max(lo, min(val, hi))
if key in _VALID_VALUES:
allowed = _VALID_VALUES[key]
if not isinstance(val, str) or val not in allowed:
raise HTTPException(
400,
f"{key} must be one of {sorted(allowed)}",
)
current[key] = val current[key] = val
_save_settings(current) _save_settings(current)
return without_retired_settings(current) return without_retired_settings(current)
+1 -2
View File
@@ -1276,6 +1276,7 @@ async def build_chat_context(
message=_ctx_msg, message=_ctx_msg,
session=sess, session=sess,
use_web=use_web_val and not skip_web, use_web=use_web_val and not skip_web,
use_rag=use_rag_val,
use_memory=mem_enabled, use_memory=mem_enabled,
time_filter=time_filter, time_filter=time_filter,
preset_system_prompt=preset.system_prompt, preset_system_prompt=preset.system_prompt,
@@ -1286,8 +1287,6 @@ async def build_chat_context(
incognito=incognito, incognito=incognito,
use_skills=skills_enabled, use_skills=skills_enabled,
) )
if use_rag is not None or is_research_spinoff or casual_low_signal:
_preface_kwargs["use_rag"] = use_rag_val
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs) preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
# Capture used memories immediately # Capture used memories immediately
+17 -1
View File
@@ -1901,7 +1901,7 @@ def _ts_or_zero(value) -> float:
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool: def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
"""Re-populate sess.model from the matching endpoint's cached models. """Recover sess.model from the endpoint's visible cached and pinned models.
Covers the window between endpoint setup and the first chat send: the Covers the window between endpoint setup and the first chat send: the
picker showed a model in the dropdown but the session record never got picker showed a model in the dropdown but the session record never got
@@ -1965,6 +1965,9 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
visible = _visible_models(cached, getattr(ep, "hidden_models", None)) visible = _visible_models(cached, getattr(ep, "hidden_models", None))
except Exception: except Exception:
visible = cached visible = cached
if not is_chatgpt_subscription:
from src.endpoint_resolver import _endpoint_enabled_models
visible = _endpoint_enabled_models(ep)
if current_model and current_model in {str(item).strip() for item in visible}: if current_model and current_model in {str(item).strip() for item in visible}:
return False return False
if is_chatgpt_subscription: if is_chatgpt_subscription:
@@ -3923,6 +3926,19 @@ def setup_chat_routes(
if ctx.preprocessed.attachment_meta: if ctx.preprocessed.attachment_meta:
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n" yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
# Give the page the saved user message's id. Only the reply's id
# was sent back, so a message sent in the open page had none and
# a later regenerate/edit of it could not cut the chat by id.
try:
_last_user_msg = next((m for m in reversed(getattr(sess, "history", None) or [])
if getattr(m, "role", None) == "user"), None)
_user_db_id = ((getattr(_last_user_msg, "metadata", None) or {}).get("_db_id")
if _last_user_msg is not None else None)
if _user_db_id:
yield f"data: {json.dumps({'type': 'user_message_saved', 'id': _user_db_id})}\n\n"
except Exception:
pass
# Announce any docs auto-created during preprocess (e.g. fillable # Announce any docs auto-created during preprocess (e.g. fillable
# PDF → editable markdown) so the editor pane switches to them # PDF → editable markdown) so the editor pane switches to them
# before the model starts streaming. # before the model starts streaming.
+14
View File
@@ -1491,6 +1491,20 @@ def _diagnose_serve_output(text: str) -> dict | None:
"Port is already in use.", "Port is already in use.",
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}], [{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
), ),
(
# PyPI torch/vLLM wheels now target CUDA 13, which needs driver 580+.
# torch: "The NVIDIA driver on your system is too old (found version 12080)";
# vLLM kernels: "CUDA driver version is insufficient for CUDA runtime version".
r"NVIDIA driver on your system is too old|CUDA driver version is insufficient",
"The installed PyTorch/vLLM build needs a newer NVIDIA driver than this server has.",
[
{
"label": "install builds for the driver's CUDA version, e.g. vLLM's +cu129 wheel from its GitHub release with uv pip install … --torch-backend cu129",
"op": "manual",
},
{"label": "or update the NVIDIA driver (CUDA 13 builds need driver 580 or newer)", "op": "manual"},
],
),
( (
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid", r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
"No GPUs are visible to the serve process.", "No GPUs are visible to the serve process.",
+92 -232
View File
@@ -468,175 +468,6 @@ def setup_cookbook_routes() -> APIRouter:
task["payload"].pop("hf_token", None) task["payload"].pop("hf_token", None)
return state return state
def _diagnose_serve_output(text: str) -> dict | None:
"""Server-side mirror of the Cookbook UI's common serve diagnoses.
The browser uses cookbook-diagnosis.js for clickable fixes. This gives
the agent/tool path the same structured signal so it can retry with an
adjusted command instead of guessing from raw tmux output.
"""
if not text:
return None
tail = text[-6000:]
patterns = [
(
r"No available memory for the cache blocks|Available KV cache memory:.*-",
"No GPU memory left for KV cache after loading model.",
[
{"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"},
{"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"},
],
),
(
r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization",
"GPU ran out of memory during startup or warmup.",
[
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
{"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"},
{"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"},
],
),
(
r"not divisib|must be divisible|attention heads.*divisible",
"Tensor parallel size is incompatible with the model.",
[
{"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"},
{"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"},
],
),
(
r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context",
"Context length is too large for available GPU memory.",
[
{"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"},
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
],
),
(
r"enable-auto-tool-choice requires --tool-call-parser",
"Auto tool choice requires an explicit tool call parser.",
[{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}],
),
(
r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not",
"Model requires custom code or newer model support.",
[{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}],
),
(
r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer",
"vLLM/Transformers kernel package mismatch.",
[{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}],
),
(
r"Address already in use|bind.*address.*in use",
"Port is already in use.",
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
),
(
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
"No GPUs are visible to the serve process.",
[{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}],
),
(
r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available",
"vLLM could not find a supported GPU (CUDA or ROCm). "
"This machine may have integrated or unsupported graphics only.",
[
{"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"},
{"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"},
],
),
(
r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed",
"vLLM is not installed or not in PATH on this server.",
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
),
(
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
r"Could not load any common_ops library|"
r"Please ensure sgl_kernel is properly installed",
"SGLang native kernel/runtime is missing or mismatched on this server.",
[
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
],
),
(
r"sglang.*command not found|No module named sglang|SGLang is not installed",
"SGLang is not installed or not in PATH on this server.",
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
),
(
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
"MLX LM is not installed on this server.",
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
),
(
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
),
(
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
[
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
],
),
# System build deps come BEFORE the generic llama.cpp catch-all
# so cmake / build-essential / git missing → a specific OS-package
# remediation instead of "install llama-cpp-python[server]" (which
# itself fails to compile when cmake is absent).
(
r"cmake: command not found|cmake.*not found.*[Cc]ould not",
"cmake is required to build llama.cpp from source but isn't installed on this server.",
[{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler",
"A C/C++ compiler (build-essential) is required to build llama.cpp from source.",
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"^git: command not found",
"git is required to clone the llama.cpp source tree.",
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'",
"llama.cpp / llama-cpp-python dependencies are missing.",
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"No GGUF found on this host|no \.gguf file|No GGUF file found",
"No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.",
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
),
(
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
),
(
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
"Model access is gated or unauthorized.",
[{"label": "set HF token and request model access on HuggingFace", "op": "manual"}],
),
]
for pattern, message, suggestions in patterns:
if re.search(pattern, tail, re.I):
return {"message": message, "suggestions": suggestions}
if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search(
r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I
):
return {
"message": "Python traceback detected during serve startup.",
"suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}],
}
return None
def _state_for_client(state): def _state_for_client(state):
"""Return cookbook state without raw secrets for browser clients.""" """Return cookbook state without raw secrets for browser clients."""
_strip_task_secrets(state) _strip_task_secrets(state)
@@ -1577,10 +1408,15 @@ def setup_cookbook_routes() -> APIRouter:
return {"models": models, "host": host or "local"} return {"models": models, "host": host or "local"}
def _auto_register_image_endpoint(req: ServeRequest, remote: str | None) -> str | None: def _auto_register_image_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a diffusion model as an image endpoint so it appears in the model selector.""" """Register a diffusion model as an image endpoint so it appears in the model selector."""
import re import re
from core.database import SessionLocal, ModelEndpoint from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
from src.settings import load_settings, save_settings from src.settings import load_settings, save_settings
# Parse port from command (--port NNNN), default 8100 for diffusion_server # Parse port from command (--port NNNN), default 8100 for diffusion_server
@@ -1608,9 +1444,13 @@ def setup_cookbook_routes() -> APIRouter:
if existing: if existing:
existing.is_enabled = True existing.is_enabled = True
existing.model_type = "image" existing.model_type = "image"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name existing.name = display_name
existing.endpoint_kind = "local" existing.endpoint_kind = "local"
existing.model_refresh_mode = "manual" existing.model_refresh_mode = "manual"
existing.cookbook_serve_session_id = serve_session_id
if pinned_models: if pinned_models:
existing.cached_models = json.dumps(pinned_models) existing.cached_models = json.dumps(pinned_models)
existing.pinned_models = json.dumps(pinned_models) existing.pinned_models = json.dumps(pinned_models)
@@ -1634,6 +1474,7 @@ def setup_cookbook_routes() -> APIRouter:
model_refresh_mode="manual", model_refresh_mode="manual",
cached_models=json.dumps(pinned_models) if pinned_models else None, cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None, pinned_models=json.dumps(pinned_models) if pinned_models else None,
cookbook_serve_session_id=serve_session_id,
) )
db.add(ep) db.add(ep)
db.commit() db.commit()
@@ -1779,43 +1620,71 @@ def setup_cookbook_routes() -> APIRouter:
# let the probe layer mark it offline if nothing's listening. # let the probe layer mark it offline if nothing's listening.
logger.info(f"crash-watchdog: serve {session_id} exited cleanly (0); leaving endpoint {endpoint_id}") logger.info(f"crash-watchdog: serve {session_id} exited cleanly (0); leaving endpoint {endpoint_id}")
return return
# Non-zero exit — drop the endpoint. # A nonzero exit may delete only the endpoint owned by this serve.
try: try:
from core.database import SessionLocal as _SL, ModelEndpoint as _ME from core.database import SessionLocal as _SL, ModelEndpoint as _ME
from core.constants import internal_api_base as _internal_api_base
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
from urllib.parse import quote
import httpx
db = _SL() db = _SL()
try: try:
ep = db.query(_ME).filter(_ME.id == endpoint_id).first() ep = db.query(_ME).filter(_ME.id == endpoint_id).first()
if ep: if not ep or ep.cookbook_serve_session_id != session_id:
# A scheduled serve can leave old non-zero exit markers logger.info(
# in tmux scrollback while the current OpenAI endpoint is "crash-watchdog: endpoint %s no longer owned by %s",
# actually alive. Verify reachability before deleting the endpoint_id, session_id,
# endpoint row; otherwise chats fall back even though the )
# served model is ready. return
try:
probe_url = ep.base_url.rstrip("/") + "/models" probe_url = ep.base_url.rstrip("/") + "/models"
finally:
db.close()
# Preserve an endpoint that is already healthy, including
# after an old runner emits a stale failure marker.
try:
with urllib.request.urlopen(probe_url, timeout=3) as resp: with urllib.request.urlopen(probe_url, timeout=3) as resp:
if 200 <= getattr(resp, "status", 0) < 300: if 200 <= getattr(resp, "status", 0) < 300:
logger.info( logger.info(
f"crash-watchdog: serve {session_id} has exit marker {exit_code} " "crash-watchdog: endpoint %s is reachable; keeping it",
f"but endpoint {ep.id} is reachable; leaving it registered" endpoint_id,
) )
return return
except Exception: except Exception:
pass pass
async with httpx.AsyncClient(timeout=8) as client:
response = await client.delete(
f"{_internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
params={"expected_cookbook_serve_session_id": session_id},
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN},
)
if response.status_code == 200:
logger.info( logger.info(
f"crash-watchdog: dropping endpoint {endpoint_id} " "crash-watchdog: deleted endpoint %s after serve %s exited %s",
f"({ep.name} @ {ep.base_url}) — serve exited {exit_code}" endpoint_id, session_id, exit_code,
)
elif response.status_code in (404, 409):
logger.info(
"crash-watchdog: endpoint %s absent or reassigned; keeping it",
endpoint_id,
)
else:
logger.warning(
"crash-watchdog: endpoint %s deletion returned HTTP %s",
endpoint_id, response.status_code,
) )
db.delete(ep)
db.commit()
finally:
db.close()
except Exception as e: except Exception as e:
logger.warning(f"crash-watchdog: endpoint cleanup failed: {e!r}") logger.warning("crash-watchdog: endpoint cleanup failed: %r", e)
return return
logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}") logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}")
def _auto_register_llm_endpoint(req: ServeRequest, remote: str | None) -> str | None: def _auto_register_llm_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a freshly-served LLM as a model endpoint so it appears in the """Register a freshly-served LLM as a model endpoint so it appears in the
model picker without a manual /setup step — the text-model sibling of model picker without a manual /setup step — the text-model sibling of
_auto_register_image_endpoint. _auto_register_image_endpoint.
@@ -1832,6 +1701,7 @@ def setup_cookbook_routes() -> APIRouter:
) )
import re import re
from core.database import SessionLocal, ModelEndpoint from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
# Port: ordered fallbacks so we match whatever the user actually # Port: ordered fallbacks so we match whatever the user actually
# asked for, not a hardcoded default: # asked for, not a hardcoded default:
@@ -1893,8 +1763,12 @@ def setup_cookbook_routes() -> APIRouter:
# deployment alias from /v1/models, and a stale server can answer on the # deployment alias from /v1/models, and a stale server can answer on the
# same port while the new launch failed. Keeping the requested model id # same port while the new launch failed. Keeping the requested model id
# pinned makes the picker reflect the actual launch intent. # pinned makes the picker reflect the actual launch intent.
pinned_models = [mlx_shim_model_id] if mlx_shim_model_id else ([req.repo_id] if req.repo_id else []) launched_model = mlx_shim_model_id or req.repo_id
pinned_models = [launched_model] if launched_model else []
# Launch registration must not delete other endpoints.
# Matching names, model pins, and failed probes do not prove that
# another serve has stopped. Lifecycle cleanup owns termination.
db = SessionLocal() db = SessionLocal()
try: try:
# Reuse an endpoint already pointed at this URL instead of duplicating. # Reuse an endpoint already pointed at this URL instead of duplicating.
@@ -1902,19 +1776,22 @@ def setup_cookbook_routes() -> APIRouter:
if existing: if existing:
existing.is_enabled = True existing.is_enabled = True
existing.model_type = "llm" existing.model_type = "llm"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name existing.name = display_name
existing.endpoint_kind = "local" existing.endpoint_kind = "local"
existing.model_refresh_mode = "auto" existing.model_refresh_mode = "auto"
if pinned_models: # A reused endpoint belongs to the newest serve session.
try: existing.cookbook_serve_session_id = serve_session_id
existing_pinned = json.loads(existing.pinned_models or "[]") if launched_model:
except Exception: # Pins represent model selection, not launch history.
existing_pinned = [] existing_pins = _normalize_model_ids(
merged_pinned = [] existing.pinned_models
for mid in [*existing_pinned, *pinned_models]: )
if mid and mid not in merged_pinned: if launched_model not in existing_pins:
merged_pinned.append(mid) existing_pins.append(launched_model)
existing.pinned_models = json.dumps(merged_pinned) if merged_pinned else None existing.pinned_models = json.dumps(existing_pins)
if is_ollama_endpoint: if is_ollama_endpoint:
existing.endpoint_kind = "ollama" existing.endpoint_kind = "ollama"
if pinned_models: if pinned_models:
@@ -1940,22 +1817,6 @@ def setup_cookbook_routes() -> APIRouter:
db.commit() db.commit()
except Exception as _pe: except Exception as _pe:
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}") logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
# Sweep stale dupes: other endpoints with the same display name
# at DIFFERENT URLs (likely failed earlier-attempt ports) get
# deleted so the picker doesn't show an offline ghost next to
# the working one. Only sweeps endpoints whose id starts with
# `local-` so we never touch a user's hand-added DeepSeek/OpenAI/
# etc. entry with a coincidentally matching name.
stale = (db.query(ModelEndpoint)
.filter(ModelEndpoint.name == display_name)
.filter(ModelEndpoint.base_url != base_url)
.filter(ModelEndpoint.id.like("local-%"))
.all())
for s in stale:
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
db.delete(s)
if stale:
db.commit()
return existing.id return existing.id
ep_id = f"local-{uuid.uuid4().hex[:8]}" ep_id = f"local-{uuid.uuid4().hex[:8]}"
@@ -1971,22 +1832,11 @@ def setup_cookbook_routes() -> APIRouter:
cached_models=json.dumps(pinned_models) if pinned_models else None, cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None, pinned_models=json.dumps(pinned_models) if pinned_models else None,
supports_tools=supports_tools, supports_tools=supports_tools,
cookbook_serve_session_id=serve_session_id,
) )
db.add(ep) db.add(ep)
db.commit() db.commit()
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}") logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
# Same sweep on first-register path: drop any pre-existing local-*
# endpoints with this display name pointed elsewhere.
stale = (db.query(ModelEndpoint)
.filter(ModelEndpoint.name == display_name)
.filter(ModelEndpoint.id != ep_id)
.filter(ModelEndpoint.id.like("local-%"))
.all())
for s in stale:
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
db.delete(s)
if stale:
db.commit()
# Probe /v1/models NOW and write cached_models so the chat # Probe /v1/models NOW and write cached_models so the chat
# picker actually shows the model on the next /api/models # picker actually shows the model on the next /api/models
# call. Without this immediate probe, the endpoint has empty # call. Without this immediate probe, the endpoint has empty
@@ -2808,9 +2658,17 @@ def setup_cookbook_routes() -> APIRouter:
endpoint_id = None endpoint_id = None
is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd
if is_image_endpoint: if is_image_endpoint:
endpoint_id = _auto_register_image_endpoint(req, remote) endpoint_id = _auto_register_image_endpoint(
req,
remote,
serve_session_id=session_id,
)
elif not is_pip_install: elif not is_pip_install:
endpoint_id = _auto_register_llm_endpoint(req, remote) endpoint_id = _auto_register_llm_endpoint(
req,
remote,
serve_session_id=session_id,
)
# Crash watchdog: the auto-register above writes the endpoint row # Crash watchdog: the auto-register above writes the endpoint row
# IMMEDIATELY (before the server has even bound its port) so the # IMMEDIATELY (before the server has even bound its port) so the
@@ -3930,7 +3788,7 @@ def setup_cookbook_routes() -> APIRouter:
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"} return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
data = resp.json() data = resp.json()
except Exception: except Exception:
logger.exception("HF GGUF file scan failed for %s", repo) logger.exception("HF GGUF file scan failed for %s", repo_id)
return {"ok": False, "files": [], "error": "HF API request failed"} return {"ok": False, "files": [], "error": "HF API request failed"}
files = [ files = [
str(s.get("rfilename") or "") str(s.get("rfilename") or "")
@@ -4446,7 +4304,9 @@ def setup_cookbook_routes() -> APIRouter:
# fills the visible tail. Without this, output_tail ends up # fills the visible tail. Without this, output_tail ends up
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent # as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
# can't diagnose the actual error. # can't diagnose the actual error.
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-500")] # -J joins lines tmux wrapped at the pane width, so an error
# split mid-word still matches the serve diagnosis patterns.
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")]
elif IS_WINDOWS: elif IS_WINDOWS:
# LOCAL Windows task: launched as a detached process (no tmux). # LOCAL Windows task: launched as a detached process (no tmux).
# Liveness comes from the <session>.pid file, output from the # Liveness comes from the <session>.pid file, output from the
@@ -4455,7 +4315,7 @@ def setup_cookbook_routes() -> APIRouter:
capture_cmd = None capture_cmd = None
else: else:
check_cmd = ["tmux", "has-session", "-t", session_id] check_cmd = ["tmux", "has-session", "-t", session_id]
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"] capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]
local_win_task = (not remote) and IS_WINDOWS local_win_task = (not remote) and IS_WINDOWS
+9 -4
View File
@@ -16,6 +16,7 @@ from core.database import SessionLocal, Document, DocumentVersion
from core.database import Session as DbSession from core.database import Session as DbSession
from src.auth_helpers import get_current_user, _auth_disabled from src.auth_helpers import get_current_user, _auth_disabled
from src.constants import MAIL_ATTACHMENTS_DIR from src.constants import MAIL_ATTACHMENTS_DIR
from src.session_search import _escape_like
from src.upload_handler import reserve_upload_references from src.upload_handler import reserve_upload_references
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -571,9 +572,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
# match docs containing both words regardless of position/order. # match docs containing both words regardless of position/order.
if search: if search:
for tok in search.split(): for tok in search.split():
term = f"%{tok}%" term = f"%{_escape_like(tok)}%"
q = q.filter( q = q.filter(
Document.title.ilike(term) | Document.current_content.ilike(term) Document.title.ilike(term, escape="\\")
| Document.current_content.ilike(term, escape="\\")
) )
# Language filter. "pdf" is a display language derived from the # Language filter. "pdf" is a display language derived from the
@@ -812,10 +814,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
ext = _ext.get(doc.language or "text", ".txt") ext = _ext.get(doc.language or "text", ".txt")
base = (doc.title or "document").strip() or "document" base = (doc.title or "document").strip() or "document"
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
name = base if "." in base else base + ext has_ext = base.lower().endswith(ext)
stem = base[:-len(ext)] if has_ext else base
suffix = base[-len(ext):] if has_ext else ext
name = stem + suffix
i = 1 i = 1
while name in used: while name in used:
name = f"{base}-{i}" + ("" if "." in base else ext) name = f"{stem}-{i}{suffix}"
i += 1 i += 1
used.add(name) used.add(name)
content = doc.current_content or "" content = doc.current_content or ""
+82 -2
View File
@@ -846,6 +846,64 @@ def _plan_psd_steps(prompt: str) -> Optional[List[Dict[str, Any]]]:
return None return None
def _is_background_removal_intent(prompt: str) -> bool:
"""Linear-time, bounded intent parser for background removal commands.
Replaces the previous backtracking regular expression flagged by CodeQL
(#1212) while preserving the exact command grammar.
"""
if not prompt or not isinstance(prompt, str):
return False
i = len(prompt)
while i > 0 and prompt[i - 1] in ".! \t\r\n":
i -= 1
cleaned = prompt[:i].strip()
if not cleaned:
return False
tokens = cleaned.split()
if not tokens:
return False
idx = 0
if tokens[idx].lower() == "please":
idx += 1
if idx >= len(tokens):
return False
if tokens[idx].lower() not in {"remove", "erase", "delete", "clear", "strip"}:
return False
idx += 1
if idx >= len(tokens):
return False
if tokens[idx].lower() == "the":
idx += 1
if idx >= len(tokens):
return False
if tokens[idx].lower() not in {"bg", "background", "backdrop"}:
return False
idx += 1
if idx == len(tokens):
return True
if tokens[idx].lower() in {"from", "of"}:
idx += 1
if idx < len(tokens) and tokens[idx].lower() == "the":
idx += 1
if (
idx + 1 < len(tokens)
and tokens[idx].lower() in {"active", "current", "selected"}
and tokens[idx + 1].lower() == "layer"
and idx + 2 == len(tokens)
):
return True
return False
def _plan_special( def _plan_special(
prompt: str, state: Any, provider_available: bool, prompt: str, state: Any, provider_available: bool,
) -> Optional[Tuple[List[Dict[str, Any]], str, str]]: ) -> Optional[Tuple[List[Dict[str, Any]], str, str]]:
@@ -855,6 +913,8 @@ def _plan_special(
left to the model. Returns (steps, reply, note) or None. These never need an left to the model. Returns (steps, reply, note) or None. These never need an
image provider except generative border fill, used only when one exists. image provider except generative border fill, used only when one exists.
""" """
if _is_background_removal_intent(prompt):
return [{"op": "ai.removeBackground", "params": {}}], "Removing the active layer's background.", ""
# Targeted recolour is fully deterministic (object mask + masked hue shift) # Targeted recolour is fully deterministic (object mask + masked hue shift)
# and must never be left to the model, which sometimes substitutes a # and must never be left to the model, which sometimes substitutes a
# generative placeImage. Highest priority so it can never vary. # generative placeImage. Highest priority so it can never vary.
@@ -1647,6 +1707,13 @@ def setup_editor_agent_routes() -> APIRouter:
screenshot = body.get("screenshot") screenshot = body.get("screenshot")
# Optional user-picked image model (from the editor model selector). # Optional user-picked image model (from the editor model selector).
model_choice = str(body.get("model") or body.get("image_model") or "").strip() model_choice = str(body.get("model") or body.get("image_model") or "").strip()
generation_model_choice = str(body.get("generation_model") or "").strip()
use_image_settings = body.get("_use_image_settings") is True
if use_image_settings:
from src.editor_image_settings import editor_image_models
image_models = editor_image_models(owner=owner)
model_choice = image_models["edit"]
generation_model_choice = image_models["generation"]
# Optional user-picked PLANNER model (the editor agent/planner model). # Optional user-picked PLANNER model (the editor agent/planner model).
agent_model = str(body.get("agent_model") or "").strip() agent_model = str(body.get("agent_model") or "").strip()
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled] tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
@@ -1797,9 +1864,22 @@ def setup_editor_agent_routes() -> APIRouter:
# Planner output is untrusted: it cannot redirect this turn to # Planner output is untrusted: it cannot redirect this turn to
# another editor session or override an explicit model choice. # another editor session or override an explicit model choice.
tool_payload["session"] = session tool_payload["session"] = session
if use_image_settings:
tool_payload["_use_image_settings"] = True
if op == "generate_image" and state.get("width") and state.get("height"):
try:
width, height = int(state["width"]), int(state["height"])
if width > 0 and height > 0:
tool_payload["size"] = f"{width}x{height}"
except (TypeError, ValueError):
pass
# Route to the user-picked model when one is selected. # Route to the user-picked model when one is selected.
if model_choice: selected_model = generation_model_choice if op == "generate_image" else model_choice
tool_payload["model"] = model_choice if selected_model:
tool_payload["model"] = selected_model
elif op == "generate_image" or use_image_settings:
# Let server settings resolve Auto, not a planner-invented model.
tool_payload.pop("model", None)
# Hand the AI step the canvas image the client already captured # Hand the AI step the canvas image the client already captured
# for this request. Without it the tool falls back to a separate # for this request. Without it the tool falls back to a separate
# bridge screenshot, which is not reliably answered right after a # bridge screenshot, which is not reliably answered right after a
+108 -27
View File
@@ -18,6 +18,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
from core.database import Session as DbSession from core.database import Session as DbSession
from src.auth_helpers import get_current_user, owner_filter, require_privilege from src.auth_helpers import get_current_user, owner_filter, require_privilege
from src.session_search import escape_like_pattern
from src.upload_limits import ( from src.upload_limits import (
read_upload_limited, read_upload_limited,
GALLERY_UPLOAD_MAX_BYTES, GALLERY_UPLOAD_MAX_BYTES,
@@ -37,6 +38,24 @@ _SAM_STATE: Dict[str, Any] = {}
_GROUNDING_STATE: Dict[str, Any] = {} _GROUNDING_STATE: Dict[str, Any] = {}
def _compose_cloud_image_edit(source, generated, mask, *, instruction=False):
from PIL import Image, ImageChops, ImageOps
full_frame = instruction or mask.getextrema() == (255, 255)
if full_frame:
return generated, True
# Partial edits must share the source coordinate system, but resizing
# width/height independently distorts the subject when the provider changes ratio.
fitted = ImageOps.contain(generated, source.size, Image.Resampling.LANCZOS)
offset = ((source.width - fitted.width) // 2, (source.height - fitted.height) // 2)
aligned = Image.new("RGBA", source.size)
aligned.paste(fitted, offset)
coverage = Image.new("L", source.size)
coverage.paste(255, (offset[0], offset[1], offset[0] + fitted.width, offset[1] + fitted.height))
effective_mask = ImageChops.multiply(mask, coverage)
return Image.composite(aligned, source, effective_mask), False
def _rss_mb() -> float: def _rss_mb() -> float:
"""Current process RSS in MB (from /proc; -1.0 when unavailable).""" """Current process RSS in MB (from /proc; -1.0 when unavailable)."""
try: try:
@@ -305,6 +324,23 @@ def _b64_bytes(value: str, *, label: str = "image") -> bytes:
raise HTTPException(400, f"Invalid {label}") from exc raise HTTPException(400, f"Invalid {label}") from exc
async def _json_object(request: Request) -> dict:
try:
data = await request.json()
except ValueError:
raise HTTPException(400, "Invalid JSON body")
if not isinstance(data, dict):
raise HTTPException(400, "JSON object body required")
return data
async def _image_ids(request: Request) -> list:
ids = (await _json_object(request)).get("image_ids", [])
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
raise HTTPException(400, "image_ids must be a list of image ids")
return ids
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str: def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format=fmt) img.save(buf, format=fmt)
@@ -568,6 +604,7 @@ def _is_openai_api_base(url: str) -> bool:
_GALLERY_ENDPOINT_PATHS = frozenset({ _GALLERY_ENDPOINT_PATHS = frozenset({
"/chat/completions",
"/images", "/images",
"/images/edits", "/images/edits",
"/images/generations", "/images/generations",
@@ -1184,14 +1221,17 @@ def setup_gallery_routes() -> APIRouter:
) )
q = _owner_filter(q, user) q = _owner_filter(q, user)
# Search filter (prompt + tags + ai_tags) # Per-term AND search: a single `%foo bar%` LIKE only matched the
# exact phrase, so "red car" missed "a car that is red".
# LIKE wildcards are escaped per token.
if search: if search:
term = f"%{search}%"
from sqlalchemy import or_ from sqlalchemy import or_
for tok in search.split():
term = f"%{escape_like_pattern(tok)}%"
q = q.filter(or_( q = q.filter(or_(
GalleryImage.prompt.ilike(term), GalleryImage.prompt.ilike(term, escape="\\"),
GalleryImage.tags.ilike(term), GalleryImage.tags.ilike(term, escape="\\"),
GalleryImage.ai_tags.ilike(term), GalleryImage.ai_tags.ilike(term, escape="\\"),
)) ))
# Tag filter. The UI stacks multiple tag pills by passing them # Tag filter. The UI stacks multiple tag pills by passing them
@@ -1203,9 +1243,10 @@ def setup_gallery_routes() -> APIRouter:
for one in (t.strip() for t in tag.split(",")): for one in (t.strip() for t in tag.split(",")):
if not one: if not one:
continue continue
safe_tag = f"%{escape_like_pattern(one)}%"
q = q.filter(_or( q = q.filter(_or(
GalleryImage.tags.ilike(f"%{one}%"), GalleryImage.tags.ilike(safe_tag, escape="\\"),
GalleryImage.ai_tags.ilike(f"%{one}%"), GalleryImage.ai_tags.ilike(safe_tag, escape="\\"),
)) ))
# Model filter # Model filter
@@ -1745,6 +1786,17 @@ def setup_gallery_routes() -> APIRouter:
if not ok: if not ok:
raise HTTPException(400, f"Rejected endpoint URL: {reason}") raise HTTPException(400, f"Rejected endpoint URL: {reason}")
chosen_model = (body.pop("_model", "") or "").strip() chosen_model = (body.pop("_model", "") or "").strip()
if body.pop("_use_image_settings", False) is True:
from src.editor_image_settings import editor_image_models
from src.ai_interaction import _resolve_model
chosen_model = editor_image_models(owner=user)["edit"]
if not chosen_model:
raise HTTPException(400, "Choose an image edit model in AI settings")
try:
edit_url, chosen_model, _ = await asyncio.to_thread(_resolve_model, chosen_model, owner=user)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
requested_base = edit_url.removesuffix("/chat/completions").rstrip("/")
# Optional client-supplied id so GET /api/image/edit-progress can poll the # Optional client-supplied id so GET /api/image/edit-progress can poll the
# provider's step progress for this op. Ignored by providers that do not # provider's step progress for this op. Ignored by providers that do not
# implement the progress contract. # implement the progress contract.
@@ -1862,13 +1914,29 @@ def setup_gallery_routes() -> APIRouter:
mask_png.save(reference_mask, format="PNG") mask_png.save(reference_mask, format="PNG")
references = [src_buf.getvalue(), reference_mask.getvalue()] references = [src_buf.getvalue(), reference_mask.getvalue()]
payload = { payload = {
"model": oa_model, "size": size, "n": 1, "model": oa_model, "n": 1,
"output_format": "png", "output_format": "png",
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")), "prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
"input_references": [{"type": "image_url", "image_url": { "input_references": [{"type": "image_url", "image_url": {
"url": "data:image/png;base64," + base64.b64encode(value).decode(), "url": "data:image/png;base64," + base64.b64encode(value).decode(),
}} for value in references], }} for value in references],
} }
from src.image_model_ids import image_aspect_ratio
payload["aspect_ratio"] = image_aspect_ratio(source_png.width, source_png.height)
from src.ai_interaction import _uses_openrouter_chat_images
if _uses_openrouter_chat_images(oa_model):
payload = {
"model": oa_model,
"messages": [{"role": "user", "content": [
{"type": "text", "text": payload["prompt"]},
*[{"type": "image_url", "image_url": ref["image_url"]}
for ref in payload["input_references"]],
]}],
"modalities": ["image", "text"], "stream": False,
"image_config": {"aspect_ratio": payload["aspect_ratio"]},
}
r = await client.post(_join_checked_gallery_endpoint(base, "/chat/completions"), headers=headers, json=payload)
else:
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload) r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
else: else:
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files) r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
@@ -1885,6 +1953,12 @@ def setup_gallery_routes() -> APIRouter:
pass pass
raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})") raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})")
result = r.json() result = r.json()
from src.ai_interaction import _chat_image_results, _image_response_error
provider_error = _image_response_error(result, oa_model)
if provider_error:
raise HTTPException(400 if "deprecated" in provider_error.lower() else 502, provider_error)
if is_openrouter and _uses_openrouter_chat_images(oa_model):
result = {"data": _chat_image_results(result)}
raw_b64 = None raw_b64 = None
if result.get("data"): if result.get("data"):
item = result["data"][0] item = result["data"][0]
@@ -1904,17 +1978,13 @@ def setup_gallery_routes() -> APIRouter:
# the masked region actually changes. # the masked region actually changes.
try: try:
generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA") generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA")
# Match the generated image to the source dims. blended, full_frame = _compose_cloud_image_edit(
if generated.size != source_png.size: source_png, generated, mask_png,
generated = generated.resize(source_png.size, Image.LANCZOS) instruction=body.get("mode") == "instruction",
# mask_png: white = regenerate (use generated), )
# black = keep (use source).
# Composite: result = source * (1 - mask_norm) + generated * mask_norm
# Image.composite does exactly that with `mask`.
blended = Image.composite(generated, source_png, mask_png)
out_buf = io.BytesIO() out_buf = io.BytesIO()
blended.save(out_buf, format="PNG") blended.save(out_buf, format="PNG")
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model} return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model, "full_frame": full_frame}
except Exception as comp_err: except Exception as comp_err:
# Never return a full-image edit when masking fails. # Never return a full-image edit when masking fails.
logger.warning(f"Inpaint compose failed: {comp_err}") logger.warning(f"Inpaint compose failed: {comp_err}")
@@ -2571,7 +2641,9 @@ def setup_gallery_routes() -> APIRouter:
height = int((body or {}).get("height") or 0) height = int((body or {}).get("height") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
width = height = 0 width = height = 0
if height > width: if width > 0 and height > 0:
size = f"{width}x{height}"
elif height > width:
size = "1024x1536" size = "1024x1536"
elif width > height: elif width > height:
size = "1536x1024" size = "1536x1024"
@@ -2584,6 +2656,9 @@ def setup_gallery_routes() -> APIRouter:
# The editor's model picker may choose a specific (cloud) image model; # The editor's model picker may choose a specific (cloud) image model;
# forward it so settings defaults only apply when none is selected. # forward it so settings defaults only apply when none is selected.
model_choice = str((body or {}).get("model") or "").strip() model_choice = str((body or {}).get("model") or "").strip()
if body.get("_use_image_settings") is True:
from src.editor_image_settings import editor_image_models
model_choice = editor_image_models(owner=user)["generation"]
payload = {"prompt": prompt, "size": size} payload = {"prompt": prompt, "size": size}
if model_choice: if model_choice:
payload["model"] = model_choice payload["model"] = model_choice
@@ -2939,14 +3014,22 @@ def setup_gallery_routes() -> APIRouter:
@router.put("/api/gallery/albums/{album_id}") @router.put("/api/gallery/albums/{album_id}")
async def update_album(request: Request, album_id: str): async def update_album(request: Request, album_id: str):
user = get_current_user(request) user = get_current_user(request)
data = await request.json() data = await _json_object(request)
name = data.get("name")
if name is not None:
if not isinstance(name, str) or not name.strip():
raise HTTPException(400, "Album name required")
name = name.strip()
description = data.get("description")
if description is not None and not isinstance(description, str):
raise HTTPException(400, "Album description must be a string")
db = SessionLocal() db = SessionLocal()
try: try:
album = _get_or_404_album(db, album_id, user) album = _get_or_404_album(db, album_id, user)
if data.get("name") is not None: if name is not None:
album.name = data["name"] album.name = name
if data.get("description") is not None: if description is not None:
album.description = data["description"] album.description = description
if data.get("cover_id") is not None: if data.get("cover_id") is not None:
cover_id = data["cover_id"] or None cover_id = data["cover_id"] or None
if cover_id: if cover_id:
@@ -2976,8 +3059,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/albums/{album_id}/add") @router.post("/api/gallery/albums/{album_id}/add")
async def add_to_album(request: Request, album_id: str): async def add_to_album(request: Request, album_id: str):
user = get_current_user(request) user = get_current_user(request)
data = await request.json() ids = await _image_ids(request)
ids = data.get("image_ids", [])
db = SessionLocal() db = SessionLocal()
try: try:
_get_or_404_album(db, album_id, user) _get_or_404_album(db, album_id, user)
@@ -2994,8 +3076,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/albums/{album_id}/remove") @router.post("/api/gallery/albums/{album_id}/remove")
async def remove_from_album(request: Request, album_id: str): async def remove_from_album(request: Request, album_id: str):
user = get_current_user(request) user = get_current_user(request)
data = await request.json() ids = await _image_ids(request)
ids = data.get("image_ids", [])
db = SessionLocal() db = SessionLocal()
try: try:
_get_or_404_album(db, album_id, user) _get_or_404_album(db, album_id, user)
+17
View File
@@ -825,6 +825,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
try: try:
body = await request.json() body = await request.json()
keep_count = body.get("keep_count", 0) keep_count = body.get("keep_count", 0)
# A message id is exact; keep_count from the client counts only the
# messages loaded in the page. before_msg_id forks before that
# message, through_msg_id includes it.
before_msg_id = str(body.get("before_msg_id") or "").strip()
through_msg_id = str(body.get("through_msg_id") or "").strip()
if before_msg_id or through_msg_id:
db = SessionLocal()
try:
all_db_messages = db.query(DbChatMessage).filter(
DbChatMessage.session_id == session_id
).order_by(DbChatMessage.timestamp).all()
finally:
db.close()
resolved = _keep_count_before_message(all_db_messages, before_msg_id or through_msg_id)
if resolved is None:
raise HTTPException(404, "Message not found")
keep_count = resolved + (0 if before_msg_id else 1)
# Get the source session. keep_count indexes into source.history, # Get the source session. keep_count indexes into source.history,
# so this must go through get_session — reading the cache directly # so this must go through get_session — reading the cache directly
+29 -4
View File
@@ -659,7 +659,7 @@ _NON_CHAT_CONTAINS = (
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever", "gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
"topic-control", "calibration", "topic-control", "calibration",
"ai-synthetic-video", "cosmos-reason2", "ai-synthetic-video", "cosmos-reason2",
"bge", "llama-guard", "bge", "llama-guard", "-embed", "rerank",
) )
_NON_CHAT_EXACT_PREFIXES = ( _NON_CHAT_EXACT_PREFIXES = (
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat) "gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
@@ -2846,11 +2846,12 @@ def setup_model_routes(model_discovery):
return {"endpoint_id": "", "endpoint_url": "", "model": ""} return {"endpoint_id": "", "endpoint_url": "", "model": ""}
base = _normalize_base(ep.base_url) base = _normalize_base(ep.base_url)
chat_url = build_chat_url(base) chat_url = build_chat_url(base)
if model and not _is_chat_model(model):
model = ""
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)): if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
try: try:
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None)) visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
if visible: model = next((mid for mid in visible if _is_chat_model(mid)), "")
model = visible[0]
except Exception: except Exception:
pass pass
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model} return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
@@ -3043,19 +3044,43 @@ def setup_model_routes(model_discovery):
return {"dependents": _settings_using_endpoint(ep_id)} return {"dependents": _settings_using_endpoint(ep_id)}
@router.delete("/model-endpoints/{ep_id}") @router.delete("/model-endpoints/{ep_id}")
def delete_model_endpoint(ep_id: str, request: Request): def delete_model_endpoint(
ep_id: str,
request: Request,
expected_cookbook_serve_session_id: Optional[str] = None,
):
require_admin(request) require_admin(request)
db = SessionLocal() db = SessionLocal()
try: try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first() ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep or not _chatgpt_endpoint_visible(ep, request): if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found") raise HTTPException(404, "Endpoint not found")
if expected_cookbook_serve_session_id is not None:
# The DELETE itself checks ownership. An old serve must never
# mutate the settings of an endpoint reused by another serve.
if (
not ep_id.startswith(("local-", "img-"))
or not expected_cookbook_serve_session_id
):
raise HTTPException(409, "Cookbook endpoint ownership mismatch")
deleted = (
db.query(ModelEndpoint)
.filter(
ModelEndpoint.id == ep_id,
ModelEndpoint.cookbook_serve_session_id
== expected_cookbook_serve_session_id,
)
.delete(synchronize_session=False)
)
if not deleted:
raise HTTPException(409, "Cookbook endpoint ownership changed")
# Clean up any settings that reference this endpoint # Clean up any settings that reference this endpoint
cleared = _clear_settings_for_endpoint(ep_id) cleared = _clear_settings_for_endpoint(ep_id)
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id) cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url, ep.id, ep.owner) cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url, ep.id, ep.owner)
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url, ep.id, ep.owner) cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url, ep.id, ep.owner)
auth_id = getattr(ep, "provider_auth_id", None) auth_id = getattr(ep, "provider_auth_id", None)
if expected_cookbook_serve_session_id is None:
db.delete(ep) db.delete(ep)
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id) cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
db.commit() db.commit()
+6 -2
View File
@@ -118,8 +118,12 @@ def setup_prefs_routes():
async def set_pref(request: Request, key: str, body: dict): async def set_pref(request: Request, key: str, body: dict):
user = get_current_user(request) user = get_current_user(request)
prefs = _load_for_user(user) prefs = _load_for_user(user)
prefs[key] = body.get("value") val = body.get("value")
if val is None:
prefs.pop(key, None)
else:
prefs[key] = val
_save_for_user(user, prefs) _save_for_user(user, prefs)
return {"key": key, "value": prefs[key]} return {"key": key, "value": prefs.get(key)}
return router return router
+5 -14
View File
@@ -183,6 +183,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty. OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
""" """
from src.endpoint_resolver import ( from src.endpoint_resolver import (
_endpoint_enabled_models,
build_chat_url, build_chat_url,
build_headers, build_headers,
resolve_endpoint_runtime as resolve_model_endpoint_runtime, resolve_endpoint_runtime as resolve_model_endpoint_runtime,
@@ -197,7 +198,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
ep_model = (model or "").strip() ep_model = (model or "").strip()
if not ep_model: if not ep_model:
try: try:
models = json.loads(ep.cached_models) if ep.cached_models else [] models = _endpoint_enabled_models(ep)
if models: if models:
ep_model = _first_chat_model(models) ep_model = _first_chat_model(models)
except Exception: except Exception:
@@ -824,23 +825,13 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
if not ep_url or not ep_model: if not ep_url or not ep_model:
# Last resort: this user's enabled endpoint, plus legacy shared rows. # Last resort: this user's enabled endpoint, plus legacy shared rows.
from src.database import SessionLocal from src.database import SessionLocal
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
db = SessionLocal() db = SessionLocal()
try: try:
ep = _owned_enabled_endpoint(db, user) ep = _owned_enabled_endpoint(db, user)
if ep: if ep:
base = normalize_base(ep.base_url) resolved = _resolve_endpoint_runtime(ep, owner=user)
fallback_url = build_chat_url(base) if resolved:
fallback_headers = build_headers(ep.api_key, base) _merge(*resolved)
fallback_model = ""
if ep.cached_models:
try:
models = json.loads(ep.cached_models)
if models:
fallback_model = _first_chat_model(models)
except Exception:
pass
_merge(fallback_url, fallback_model, fallback_headers)
finally: finally:
db.close() db.close()
+5
View File
@@ -15,6 +15,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from services.memory.skill_format import slugify
from services.memory.skills import SkillsManager from services.memory.skills import SkillsManager
from src.auth_helpers import get_current_user from src.auth_helpers import get_current_user
from services.skills import evaluation as _evaluation from services.skills import evaluation as _evaluation
@@ -836,6 +837,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
updates = body.dict(exclude_none=True) updates = body.dict(exclude_none=True)
if not updates: if not updates:
return {"ok": True} return {"ok": True}
if "name" in updates:
new_name = slugify(updates["name"] or match.get("name"))
if new_name != match.get("name") and skills_manager.has_skill(new_name, owner=user):
raise HTTPException(409, f"A skill named '{new_name}' already exists")
ok = skills_manager.update_skill(match.get("name"), updates, owner=user) ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
if not ok: if not ok:
raise HTTPException(404, "Skill not found") raise HTTPException(404, "Skill not found")
+8 -8
View File
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
try: try:
from core.database import ModelEndpoint from core.database import ModelEndpoint
eps = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all() from src.auth_helpers import owner_filter
from src.endpoint_resolver import _endpoint_enabled_models
eps = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
ModelEndpoint, getattr(task, "owner", None),
).all()
for ep in eps: for ep in eps:
cached = [] if model in _endpoint_enabled_models(ep):
if ep.cached_models:
try:
cached = json.loads(ep.cached_models) or []
except Exception:
cached = []
if model in cached:
return ep.base_url or "" return ep.base_url or ""
except Exception: except Exception:
pass pass
File diff suppressed because it is too large Load Diff
+19
View File
@@ -492,6 +492,25 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
"tools that execute code, mutate state or cause external side effects need " "tools that execute code, mutate state or cause external side effects need "
"a separate approval. Set to 0 to opt out.", "a separate approval. Set to 0 to opt out.",
), ),
"ODYSSEUS_LOCAL_MCP_READ_TOOLS": (
"Agent loop and tool execution", USER,
"Security-relevant. Comma-separated exact MCP read-tool names "
"on configured stdio MCP servers. Only stdio servers qualify. "
"Declarations do not override built-in tool effects. Output remains "
"untrusted to prevent privilege escalation, but allows continuation to "
"declared write tools on the same stdio server without external taint. "
"Stdio transport does not guarantee network isolation. Automatic "
"same-store writes carry residual prompt-injection risk.",
),
"ODYSSEUS_LOCAL_MCP_WRITE_TOOLS": (
"Agent loop and tool execution", USER,
"Security-relevant. Comma-separated exact MCP write-tool names "
"on configured stdio MCP servers. Only stdio servers qualify. "
"Declarations do not override built-in tool effects. Allowed to execute "
"after declared local reads on the same stdio server, but blocked once "
"external untrusted context enters the run. Authorization must still "
"originate from the trusted request grant.",
),
"ODYSSEUS_MCP_ALLOWED_COMMANDS": ( "ODYSSEUS_MCP_ALLOWED_COMMANDS": (
"Agent loop and tool execution", USER, "Agent loop and tool execution", USER,
"Security-relevant. Comma-separated allowlist of MCP launcher basenames the " "Security-relevant. Comma-separated allowlist of MCP launcher basenames the "
+6 -1
View File
@@ -130,10 +130,15 @@ def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
def _split_top_level(s: str, sep: str) -> List[str]: def _split_top_level(s: str, sep: str) -> List[str]:
"""Split `s` on `sep` ignoring separators inside [] or quotes.""" """Split `s` on `sep` ignoring separators inside [] or quotes."""
out, buf, depth, quote = [], [], 0, None out, buf, depth, quote = [], [], 0, None
escaped = False
for ch in s: for ch in s:
if quote: if quote:
buf.append(ch) buf.append(ch)
if ch == quote: if escaped:
escaped = False
elif ch == "\\" and quote == '"':
escaped = True
elif ch == quote:
quote = None quote = None
continue continue
if ch in ("'", '"'): if ch in ("'", '"'):
+16
View File
@@ -500,6 +500,19 @@ class SkillsManager:
sk.path = self._skill_file(cat, nm) sk.path = self._skill_file(cat, nm)
return sk.to_dict() return sk.to_dict()
def has_skill(self, name: str, owner: Optional[str] = None) -> bool:
"""Whether `owner` already has a skill called `name`, in any category.
Update and delete find a skill by name within its owner, so two of
one owner's skills sharing a name would make them act on whichever
file is walked first.
"""
for path in self._iter_skill_files():
sk = self._read_skill(path)
if sk and sk.name == name and (sk.owner or "") == (owner or ""):
return True
return False
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool: def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
"""`skill_id` is the slug name. Allows updating any field plus """`skill_id` is the slug name. Allows updating any field plus
renames if `name` changes (file is moved on disk). renames if `name` changes (file is moved on disk).
@@ -551,6 +564,9 @@ class SkillsManager:
# Rename # Rename
new_name = slugify(updates.get("name") or sk.name) new_name = slugify(updates.get("name") or sk.name)
if new_name != sk.name: if new_name != sk.name:
if self.has_skill(new_name, owner=owner):
logger.warning(f"Skill rename target name in use: {new_name}")
return False
sk.name = new_name sk.name = new_name
# Write to potentially new path # Write to potentially new path
+29 -5
View File
@@ -14,12 +14,12 @@ import csv
import difflib import difflib
import html import html
import json import json
import logging
import os import os
import re import re
import shlex import shlex
import shutil import shutil
import time import time
import logging
import hashlib import hashlib
from src.web_recovery import WebRecoveryBudget from src.web_recovery import WebRecoveryBudget
from itertools import count from itertools import count
@@ -16994,6 +16994,20 @@ def _detect_runaway_call(call_freq, threshold=15):
sig = next((s for s, n in call_freq.items() if n >= threshold), None) sig = next((s for s, n in call_freq.items() if n >= threshold), None)
return sig.split(":", 1)[0] if sig else None return sig.split(":", 1)[0] if sig else None
def _tool_approval_gate_bypassed(
exact_approval: Optional[ExactToolApproval] = None,
) -> bool:
"""Return whether the post-external-context tool approval gate is bypassed.
``tool_approval_mode=off`` (admin setting, Settings → Agent Tools) bypasses
the gate on every run, the same effect as ``ODYSSEUS_TOOL_APPROVAL_GATE=0``.
A chat-session grant bypasses it only within the scope of its own task.
"""
return (
get_setting("tool_approval_mode", "ask") == "off"
or bool(exact_approval and exact_approval.allow_remaining_actions)
)
def _tool_result_signature(tool_result_records: list[dict]) -> str: def _tool_result_signature(tool_result_records: list[dict]) -> str:
"""Return a bounded fingerprint of the observable result of a tool batch. """Return a bounded fingerprint of the observable result of a tool batch.
@@ -21437,9 +21451,7 @@ async def stream_agent_loop(
) )
or messages_contain_external_untrusted_context(messages) or messages_contain_external_untrusted_context(messages)
), ),
approval_gate_bypassed=bool( approval_gate_bypassed=_tool_approval_gate_bypassed(exact_approval),
exact_approval and exact_approval.allow_remaining_actions
),
delegated_credential=bool(delegated_credential), delegated_credential=bool(delegated_credential),
) )
_has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context) _has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context)
@@ -24193,11 +24205,14 @@ async def stream_agent_loop(
budget_is_explicit, budget_is_explicit,
hard_max=hard_max, hard_max=hard_max,
) )
if candidate_context <= 8192: if 0 < candidate_context <= 8192:
# Small local servers often tokenize chat wrappers and tool # Small local servers often tokenize chat wrappers and tool
# results much more generously than our rough estimator. Keep # results much more generously than our rough estimator. Keep
# substantial headroom for those wrappers and generation so a # substantial headroom for those wrappers and generation so a
# follow-up tool round cannot exceed the server's n_ctx. # follow-up tool round cannot exceed the server's n_ctx.
# 0 means the window is unknown, which already gets the
# conservative default budget; clamping it as well cut every
# request to 1200 tokens and dropped open documents (#6678).
effective_budget = min( effective_budget = min(
effective_budget, effective_budget,
max(1200, int(candidate_context * 0.40)), max(1200, int(candidate_context * 0.40)),
@@ -27833,7 +27848,16 @@ async def stream_agent_loop(
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text): if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
_streamed_tool_markup += _delta_text _streamed_tool_markup += _delta_text
if not _streamed_tool_markup_complete(_streamed_tool_markup): if not _streamed_tool_markup_complete(_streamed_tool_markup):
if _streamed_tool_markup_starts(_streamed_tool_markup):
continue continue
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
# a tag prefix, but the buffer can no longer become a
# tool tag. Release it as prose instead of holding the
# rest of the answer forever.
_delta_text = _streamed_tool_markup
_streamed_tool_markup = ""
data["delta"] = _delta_text
else:
_visible_markup_tail = strip_tool_blocks( _visible_markup_tail = strip_tool_blocks(
_streamed_tool_markup, _streamed_tool_markup,
skip_fenced=True, skip_fenced=True,
+96
View File
@@ -371,6 +371,101 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
return SemanticIntent(families, selected, required_read_operation_for_request(request_text, history)) return SemanticIntent(families, selected, required_read_operation_for_request(request_text, history))
def _trusted_local_mcp_request_tools(request_text: str, intent: SemanticIntent) -> set[str]:
"""Seal local MCP operations from operator declarations and trusted request intent.
Discovery validates an operator-declared tool but cannot grant it. For the
notes family, only an explicit request for local notes admits matching
read operations and explicitly requested non-destructive write verbs. Other
MCP domains require the user to name the qualified operation explicitly.
"""
from src.tool_capabilities import (
LOCAL_MCP_READ_TOOLS_ENV, LOCAL_MCP_WRITE_TOOLS_ENV,
_local_mcp_tools, _is_declared_local_mcp_read_tool,
_is_declared_local_mcp_write_tool, capabilities_for_tool, ToolEffect,
)
from src.tool_utils import get_mcp_manager
manager = get_mcp_manager()
if manager is None:
return set()
request = request_text.casefold()
local_notes = (
intent.selected_tools is None
and intent.required_read is None
and "notes" in intent.capabilities
and re.search(r"\b(?:local|on my computer|on this machine)\b", request)
)
write_verbs = {
"create": r"\b(?:create|add|make)\b",
"add": r"\b(?:add|create)\b",
"write": r"\bwrite\b",
"save": r"\bsave\b",
"update": r"\b(?:update|edit|modify|change)\b",
"edit": r"\b(?:edit|update|modify|change)\b",
"append": r"\bappend\b",
"modify": r"\b(?:modify|edit|update|change)\b",
}
negative_write = re.search(
r"\b(?:do not|don't|never|without|no)\s+(?:\w+\s+){0,2}"
r"(?:create|add|make|write|save|update|edit|append|modify)\b",
request,
)
declared = _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV) | _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
admitted = set()
for name in sorted(declared):
parts = name.split("__", 2)
if len(parts) != 3 or parts[0] != "mcp":
continue
_, server, bare = parts
if manager.is_builtin(server):
continue
conn = manager.get_server_status(server)
if conn.get("status") != "connected" or conn.get("transport") != "stdio":
continue
resource = manager.resource_identity(name)
if resource is None or resource.namespace != "mcp" or resource.endpoint_id != f"stdio:{server}":
continue
rows = manager._tools.get(server, [])
row = next((item for item in rows if item.get("name") == bare), None)
if row is None:
continue
read_ok = _is_declared_local_mcp_read_tool(name)
write_ok = _is_declared_local_mcp_write_tool(name)
if not (read_ok or write_ok):
continue
caps = capabilities_for_tool(name)
if not caps.known or caps.effects != frozenset({
ToolEffect.READ_PRIVATE if read_ok else ToolEffect.WRITE_PRIVATE
}):
continue
ann = row.get("annotations") or {}
destructive = ann.get("destructiveHint") if isinstance(ann, dict) else getattr(ann, "destructiveHint", None)
if destructive is True:
continue
if write_ok and re.match(r"^(?:delete|remove|erase|destroy|drop|purge)(?:_|$)", bare, re.I):
continue
# Exact qualified names in the trusted request permit any configured
# and validated local operation. Model/tool content is not an input.
explicitly_named = bool(re.search(
r"(?<![A-Za-z0-9_])" + re.escape(name.casefold()) + r"(?![A-Za-z0-9_])",
request,
))
if not explicitly_named:
if not local_notes or not re.search(r"(?:^|_)notes?(?:_|$)", bare, re.I):
continue
if write_ok:
action = bare.split("_", 1)[0].casefold()
if action not in write_verbs or not re.search(write_verbs[action], request):
continue
if negative_write:
continue
admitted.add(name)
return admitted
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None, def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
history=(), policy=None, active_document=False, history=(), policy=None, active_document=False,
image_attachment=False, capabilities=None, client_runtime_context=None): image_attachment=False, capabilities=None, client_runtime_context=None):
@@ -393,6 +488,7 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
tools = {canonical_tool(operation.tool)} tools = {canonical_tool(operation.tool)}
else: else:
operation = None operation = None
tools.update(_trusted_local_mcp_request_tools(request_text, intent))
grants = [] grants = []
for name in sorted(tools | {"ask_user", "update_plan"}): for name in sorted(tools | {"ask_user", "update_plan"}):
name = canonical_tool(name) name = canonical_tool(name)
+6 -5
View File
@@ -47,6 +47,7 @@ EFFECTS_DIR = os.path.join(DATA_DIR, "effects")
_RUN_ID = re.compile(r"[a-f0-9]{32}") _RUN_ID = re.compile(r"[a-f0-9]{32}")
_TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation} _TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation}
_VERSION = 1 _VERSION = 1
_O_BINARY = getattr(os, "O_BINARY", 0) # Windows: raw os.open() is text-mode without it
def _fsync_directory(directory: str | os.PathLike) -> None: def _fsync_directory(directory: str | os.PathLike) -> None:
@@ -243,7 +244,7 @@ class EffectLog:
"""Hold the file exclusively with every durable record merged.""" """Hold the file exclusively with every durable record merged."""
assert self.path is not None assert self.path is not None
_ensure_directory(self.path.parent) _ensure_directory(self.path.parent)
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
descriptor = os.open(self.path, flags, 0o600) descriptor = os.open(self.path, flags, 0o600)
try: try:
if fcntl is not None: if fcntl is not None:
@@ -358,7 +359,7 @@ class EffectLog:
with self._lock: with self._lock:
try: try:
descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_CLOEXEC", 0)) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
except FileNotFoundError: except FileNotFoundError:
return return
except OSError: except OSError:
@@ -394,7 +395,7 @@ class EffectLog:
log = cls(run_id, directory=directory) log = cls(run_id, directory=directory)
assert log.path is not None assert log.path is not None
try: try:
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)) descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
except FileNotFoundError: except FileNotFoundError:
return log return log
except OSError as error: except OSError as error:
@@ -460,7 +461,7 @@ class EffectLog:
temporary = target.with_suffix(".tmp") temporary = target.with_suffix(".tmp")
data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode() data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode()
_ensure_directory(target.parent) _ensure_directory(target.parent)
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
descriptor = os.open(temporary, flags, 0o600) descriptor = os.open(temporary, flags, 0o600)
try: try:
view = memoryview(data) view = memoryview(data)
@@ -478,7 +479,7 @@ class EffectLog:
return None return None
base = Path(directory) if directory is not None else effects_dir() base = Path(directory) if directory is not None else effects_dir()
try: try:
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | _O_BINARY)
with os.fdopen(descriptor, "rb") as stream: with os.fdopen(descriptor, "rb") as stream:
if os.fstat(stream.fileno()).st_nlink != 1: if os.fstat(stream.fileno()).st_nlink != 1:
return None return None
+8 -2
View File
@@ -191,7 +191,7 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
db.close() db.close()
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> bool: async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None, native_size: bool = False) -> bool:
params: Dict[str, Any] = { params: Dict[str, Any] = {
"dataUrl": data_url, "dataUrl": data_url,
"name": name, "name": name,
@@ -199,6 +199,8 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
"blend": blend or "normal", "blend": blend or "normal",
"opacity": opacity, "opacity": opacity,
} }
if native_size:
params["nativeSize"] = True
# Forward the edit mode so the client can build a difference mask for # Forward the edit mode so the client can build a difference mask for
# instruction-mode results: the whole image was regenerated under an # instruction-mode results: the whole image was regenerated under an
# instruction prompt, so untouched pixels must stay pixel-identical (the # instruction prompt, so untouched pixels must stay pixel-identical (the
@@ -506,6 +508,8 @@ class AiEditImageTool:
} }
_t["image"] = time.perf_counter() _t["image"] = time.perf_counter()
payload: Dict[str, Any] = {"image": image} payload: Dict[str, Any] = {"image": image}
if args.get("_use_image_settings") is True:
payload["_use_image_settings"] = True
model_choice = str(args.get("model") or "").strip() model_choice = str(args.get("model") or "").strip()
mask_used = False mask_used = False
place_mode: Optional[str] = None place_mode: Optional[str] = None
@@ -609,10 +613,11 @@ class AiEditImageTool:
f"AI {op}", f"AI {op}",
owner, owner,
session, session,
masked=mask_used, masked=mask_used and data.get("full_frame") is not True,
blend=str(args.get("blend") or "normal"), blend=str(args.get("blend") or "normal"),
opacity=float(args.get("opacity") or 100), opacity=float(args.get("opacity") or 100),
mode=place_mode, mode=place_mode,
native_size=data.get("full_frame") is True,
) )
info["placed"] = placed info["placed"] = placed
if placed: if placed:
@@ -687,6 +692,7 @@ class GenerateImageLayerTool:
str(args.get("name") or "Generated image"), str(args.get("name") or "Generated image"),
owner, owner,
session, session,
native_size=True,
masked=bool(args.get("mask")), masked=bool(args.get("mask")),
blend=str(args.get("blend") or "normal"), blend=str(args.get("blend") or "normal"),
opacity=float(args.get("opacity") or 100), opacity=float(args.get("opacity") or 100),
+129 -16
View File
@@ -179,8 +179,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[
# served internal id. Include pinned/cached ids in the match set # served internal id. Include pinned/cached ids in the match set
# so chat sessions using the HF repo id still resolve. Do not use # so chat sessions using the HF repo id still resolve. Do not use
# stale cached aliases when the endpoint itself is unreachable. # stale cached aliases when the endpoint itself is unreachable.
if model_type == "image" and endpoint_reachable: if endpoint_reachable:
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)): # Gateway image models may live in a dedicated catalogue,
# so explicitly pinned IDs are valid on mixed endpoints too.
extras = _json_list(getattr(ep, "pinned_models", None))
if model_type == "image":
extras += _json_list(getattr(ep, "cached_models", None))
for extra in extras:
if extra not in model_ids: if extra not in model_ids:
model_ids.append(extra) model_ids.append(extra)
@@ -1176,9 +1181,9 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
request on this so it never falls back to deterministic approximations. request on this so it never falls back to deterministic approximations.
""" """
try: try:
from src.settings import load_settings from src.settings import get_user_setting, load_settings
if not load_settings().get("image_gen_enabled", True): if not load_settings().get("image_gen_enabled", True) or not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
return False return False
except Exception: except Exception:
pass pass
@@ -1200,6 +1205,39 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
return False return False
def _uses_openrouter_chat_images(model_id: str) -> bool:
# GPT-5 Image is an LLM wrapper, unlike native gpt-image-* image models.
leaf = model_id_leaf(model_id).lower()
return leaf.startswith("gpt-") and "-image" in leaf and not leaf.startswith("gpt-image-")
def _chat_image_results(data: dict) -> list:
images = []
for choice in data.get("choices") or []:
message = choice.get("message") or {}
for item in message.get("images") or []:
url = (item.get("image_url") or {}).get("url")
if not isinstance(url, str):
continue
if url.startswith("data:image/") and ";base64," in url:
images.append({"b64_json": url.split(",", 1)[1]})
elif url.startswith(("https://", "http://")):
images.append({"url": url})
return images
def _image_response_error(data: dict, model_id: str) -> Optional[str]:
errors = [data.get("error")]
errors.extend(choice.get("error") for choice in data.get("choices") or [])
for error in errors:
if not error:
continue
message = str(error.get("message") or "Image provider failed") if isinstance(error, dict) else str(error)
hint = " Choose a currently supported image model in AI settings." if "deprecated" in message.lower() else ""
return f"Image provider failed for '{model_id}': {message}{hint}"
return None
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict: 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).
@@ -1236,21 +1274,27 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
if not prompt: if not prompt:
return {"error": "Image prompt is required (line 1)"} return {"error": "Image prompt is required (line 1)"}
# Load admin settings for defaults # Load settings for defaults
try: try:
from src.settings import load_settings from src.settings import get_user_setting, load_settings
_settings = load_settings() _settings = load_settings()
except Exception: except Exception:
_settings = {} _settings = {}
def get_user_setting(key, owner="", default=None):
return default
# The administrator's global image-generation disable switch is authoritative.
# Non-admin preferences cannot re-enable generation when disabled globally.
if not _settings.get("image_gen_enabled", True): if not _settings.get("image_gen_enabled", True):
return {"error": "Image generation is disabled by the administrator."} return {"error": "Image generation is disabled by the administrator."}
if not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
return {"error": "Image generation is disabled by user preference."}
# Use admin-configured model/quality if not specified by the tool call # Use user- or admin-configured model/quality if not specified by the tool call
if not model_spec: if not model_spec:
model_spec = _settings.get("image_model", "") model_spec = get_user_setting("image_model", owner=owner, default="") or ""
if not quality: if not quality:
quality = _settings.get("image_quality") or "medium" quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
# Auto-detect best available image model if still not set # Auto-detect best available image model if still not set
if not model_spec: if not model_spec:
@@ -1342,8 +1386,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/") base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
images_url = base_url + "/images/generations" images_url = base_url + "/images/generations"
from src.model_capability_readers.base import detect_vendor from src.model_capability_readers.base import detect_vendor
chat_images = detect_vendor(url) == "openrouter" and _uses_openrouter_chat_images(model_id)
if detect_vendor(url) == "openrouter": if detect_vendor(url) == "openrouter":
images_url = base_url + "/images" images_url = base_url + "/images"
if chat_images:
images_url = base_url + "/chat/completions"
# Capture the requested geometry before older OpenAI size restrictions.
from src.image_model_ids import image_aspect_ratio
try:
requested_width, requested_height = (int(value) for value in size.lower().split("x"))
requested_ratio = image_aspect_ratio(requested_width, requested_height)
except (TypeError, ValueError):
requested_ratio = "1:1"
# Validate size for cloud image models (local diffusion accepts any WxH) # Validate size for cloud image models (local diffusion accepts any WxH)
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"} valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
@@ -1359,6 +1414,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
"n": 1, "n": 1,
"size": size, "size": size,
} }
if detect_vendor(url) == "openrouter":
payload.pop("size", None)
payload["aspect_ratio"] = requested_ratio
# GPT image models and local diffusion support quality; DALL-E does not # GPT image models and local diffusion support quality; DALL-E does not
if is_gpt_image or is_local_diffusion: if is_gpt_image or is_local_diffusion:
@@ -1372,7 +1430,16 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
try: try:
# GPT image models can take 30-120s+ depending on quality # GPT image models can take 30-120s+ depending on quality
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
resp = await client.post(images_url, json=payload, headers=headers) request_payload = payload
if chat_images:
request_payload = {
"model": model_id,
"messages": [{"role": "user", "content": prompt}],
"modalities": ["image", "text"],
"stream": False,
"image_config": {"aspect_ratio": requested_ratio},
}
resp = await client.post(images_url, json=request_payload, headers=headers)
if resp.status_code != 200: if resp.status_code != 200:
error_text = resp.text[:500] error_text = resp.text[:500]
@@ -1387,9 +1454,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
} }
data = resp.json() data = resp.json()
images = data.get("data", []) provider_error = _image_response_error(data, model_id)
if provider_error:
return {"error": provider_error, "untrusted_content": True}
images = _chat_image_results(data) if chat_images else data.get("data", [])
if not images: if not images:
return {"error": "No images returned from API"} return {"error": (
f"Image model '{model_id}' returned no image. "
"Choose another image generation model in AI settings."
)}
img = images[0] img = images[0]
image_url = None image_url = None
@@ -1502,21 +1575,27 @@ async def do_edit_image(
return {"error": "Attached image file was not found"} return {"error": "Attached image file was not found"}
try: try:
from src.settings import load_settings from src.settings import get_user_setting, load_settings
_settings = load_settings() _settings = load_settings()
except Exception: except Exception:
_settings = {} _settings = {}
def get_user_setting(key, owner="", default=None):
return default
if not model_spec: if not model_spec:
model_spec = _settings.get("image_model", "") model_spec = get_user_setting("image_edit_model", owner=owner) or get_user_setting("image_model", owner=owner, default="")
if quality is None: if quality is None:
quality = _settings.get("image_quality") or "medium" quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
if not model_spec: if not model_spec:
return {"error": "No image model selected for image editing"} return {"error": "No image model selected for image editing"}
try: try:
try: try:
def _call(): def _call():
try:
return _resolve_model(model_spec, owner=owner)
except ValueError:
pass
try: try:
return _resolve_model(model_spec, owner=owner, model_type="image") return _resolve_model(model_spec, owner=owner, model_type="image")
except TypeError as exc: except TypeError as exc:
@@ -1529,6 +1608,7 @@ async def do_edit_image(
except ValueError: except ValueError:
return {"error": f"No endpoint found with image model '{model_spec}'."} return {"error": f"No endpoint found with image model '{model_spec}'."}
requested_edit_size = size
if not size or size == "auto": if not size or size == "auto":
from PIL import Image from PIL import Image
from src.image_model_ids import image_edit_size from src.image_model_ids import image_edit_size
@@ -1704,6 +1784,34 @@ async def do_edit_image(
"image_url": {"url": f"data:{mime};base64,{image_b64}"}, "image_url": {"url": f"data:{mime};base64,{image_b64}"},
}], }],
} }
from src.image_model_ids import image_aspect_ratio
from PIL import Image as ReferenceImage
with ReferenceImage.open(path) as reference:
reference_width, reference_height = reference.size
if reference.getexif().get(274) in {5, 6, 7, 8}:
reference_width, reference_height = reference_height, reference_width
if requested_edit_size and requested_edit_size != "auto":
try:
reference_width, reference_height = (int(value) for value in requested_edit_size.lower().split("x"))
except (TypeError, ValueError):
pass
edit_payload.pop("size", None)
edit_payload["aspect_ratio"] = image_aspect_ratio(reference_width, reference_height)
if _uses_openrouter_chat_images(model_id):
edit_payload = {
"model": model_id,
"messages": [{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {
"url": f"data:{mime};base64,{image_b64}",
}},
]}],
"modalities": ["image", "text"],
"stream": False,
"image_config": {"aspect_ratio": edit_payload["aspect_ratio"]},
}
resp = await client.post(base_url + "/chat/completions", json=edit_payload, headers=headers)
else:
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers) resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
else: else:
with path.open("rb") as f: with path.open("rb") as f:
@@ -1747,7 +1855,12 @@ async def do_edit_image(
} }
data = resp.json() data = resp.json()
images = data.get("data", []) provider_error = _image_response_error(data, model_id)
if provider_error:
return {"error": provider_error, "untrusted_content": True}
images = (_chat_image_results(data)
if is_openrouter and _uses_openrouter_chat_images(model_id)
else data.get("data", []))
if not images: if not images:
return {"error": "No image returned from edit API"} return {"error": "No image returned from edit API"}
+35 -3
View File
@@ -10,7 +10,7 @@ import os
import json import json
import re import re
import time import time
from datetime import datetime from datetime import datetime, timezone
from typing import Tuple from typing import Tuple
from src.auth_helpers import owner_filter from src.auth_helpers import owner_filter
@@ -1957,6 +1957,23 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
return str(e), False return str(e), False
def _daily_brief_utc_boundary(local_dt, tz=None):
"""Convert a local wall-clock boundary using the offset at that boundary."""
if local_dt.tzinfo is None and tz is not None:
local_dt = local_dt.replace(tzinfo=tz)
return local_dt.astimezone(timezone.utc).replace(tzinfo=None)
def _daily_brief_local_start(event, tz=None):
"""Keep date-only/floating rows local; convert only stored UTC instants."""
start = event.dtstart
if event.is_utc and not event.all_day:
if start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
return start.astimezone(tz).replace(tzinfo=None)
return start
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]: async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
"""Build a short morning digest: today's calendar events, unread email count """Build a short morning digest: today's calendar events, unread email count
+ top-N senders/subjects, active todos.""" + top-N senders/subjects, active todos."""
@@ -1966,10 +1983,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
from routes.email_helpers import _imap_connect, _decode_header from routes.email_helpers import _imap_connect, _decode_header
from sqlalchemy import and_, or_
# ----- Calendar: today's events ----- # ----- Calendar: today's events -----
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0) today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow = today + _td(days=1) tomorrow = today + _td(days=1)
utc_today = _daily_brief_utc_boundary(today)
utc_tomorrow = _daily_brief_utc_boundary(tomorrow)
# v2 review HIGH-12: gate the OR-null branch on single-user # v2 review HIGH-12: gate the OR-null branch on single-user
# (unconfigured) deploys only. In a multi-user deploy, one # (unconfigured) deploys only. In a multi-user deploy, one
# user's daily brief must not include another user's notes or # user's daily brief must not include another user's notes or
@@ -1982,13 +2002,25 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
db = SessionLocal() db = SessionLocal()
try: try:
ev_q = db.query(CalendarEvent).join(CalendarCal).filter( ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
or_(
and_(
CalendarEvent.is_utc.is_(True),
CalendarEvent.all_day.is_not(True),
CalendarEvent.dtstart < utc_tomorrow,
CalendarEvent.dtend > utc_today,
),
and_(
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
CalendarEvent.dtstart < tomorrow, CalendarEvent.dtstart < tomorrow,
CalendarEvent.dtend > today, CalendarEvent.dtend > today,
),
),
CalendarEvent.status != "cancelled", CalendarEvent.status != "cancelled",
) )
if owner: if owner:
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null) ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
events = ev_q.order_by(CalendarEvent.dtstart).all() events = ev_q.all()
events.sort(key=_daily_brief_local_start)
# ----- Notes: pinned + non-archived todos with at least one undone item ----- # ----- Notes: pinned + non-archived todos with at least one undone item -----
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712 n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
if owner: if owner:
@@ -2058,7 +2090,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
if events: if events:
plain.append("Calendar:") plain.append("Calendar:")
for e in events: for e in events:
t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day" t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day"
loc = f" @ {e.location}" if e.location else "" loc = f" @ {e.location}" if e.location else ""
plain.append(f" {t} {e.summary}{loc}") plain.append(f" {t} {e.summary}{loc}")
plain.append("") plain.append("")
+7 -1
View File
@@ -282,7 +282,13 @@ class ChatHandler:
_sync_upload_vision_to_gallery(file_info, owner, vl_desc) _sync_upload_vision_to_gallery(file_info, owner, vl_desc)
except Exception: except Exception:
pass pass
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}" enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n"
if vl_desc:
# A separate vision call wrote this, but the chat model sees
# one message and answers the prose as if the user wrote it
# (#5573). Label it below the marker line — the client strips
# the block by matching `[Image: <name>]\n`.
enhanced_message += f"[Image description — context only, not part of the user's message]:\n{vl_desc}"
# Surface the description to the client live so it renders as a # Surface the description to the client live so it renders as a
# collapsible "image description" on the user bubble (not just # collapsible "image description" on the user bubble (not just
# after a refresh that re-parses the stored message). # after a refresh that re-parses the stored message).
+29 -49
View File
@@ -31,63 +31,43 @@ def _internal_headers() -> dict:
async def _delete_endpoint_for_task(task: dict) -> None: async def _delete_endpoint_for_task(task: dict) -> None:
"""Drop the auto-registered model endpoint for a scheduled-stop serve. """Remove a stopped serve's endpoint using the normal deletion contract."""
import re
from urllib.parse import quote
Without this, killing the tmux session leaves the endpoint sitting in endpoint_id = str(task.get("_endpointId") or task.get("endpointId") or "").strip()
the picker (probe goes offline; chats still try to route there) and serve_session_id = str(task.get("sessionId") or task.get("id") or "").strip()
the user has to delete it by hand in Settings -> Endpoints. if (
""" not re.fullmatch(r"(?:local|img)-[0-9a-f]{8}", endpoint_id)
endpoint_id = (task.get("_endpointId") or task.get("endpointId") or "").strip() or not serve_session_id
if not endpoint_id: ):
logger.info( logger.info("cookbook_serve_lifecycle: missing endpoint/session identity")
"cookbook_serve_lifecycle: task %s has no endpoint id; skipping endpoint deletion",
task.get("sessionId") or task.get("id") or "",
)
return return
import re as _re
payload = task.get("payload") or {}
cmd = str(payload.get("_cmd") or "")
remote = task.get("remoteHost") or ""
# Build host the same way _auto_register_llm_endpoint does so URL match wins.
if remote:
host = remote.split("@")[-1] if "@" in remote else remote
else:
host = "host.docker.internal"
port_match = _re.search(r"--port\s+(\d+)", cmd)
ollama_host_match = _re.search(r"OLLAMA_HOST=[^\s]*?:(\d+)", cmd)
if port_match:
port = int(port_match.group(1))
elif ollama_host_match:
port = int(ollama_host_match.group(1))
elif "ollama" in cmd:
port = 11434
else:
port = 8080
base_url = f"http://{host}:{port}/v1"
try: try:
async with httpx.AsyncClient(timeout=8) as client: async with httpx.AsyncClient(timeout=8) as client:
r = await client.get( response = await client.delete(
f"{internal_api_base()}/api/model-endpoints", f"{internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
headers=_internal_headers(), params={"expected_cookbook_serve_session_id": serve_session_id},
)
if r.status_code >= 400:
return
eps = r.json() if r.content else []
# Delete only the endpoint created by this scheduled serve. URL
# matching is unsafe because a later scheduled serve can reuse the
# same host:port after an older task has gone stale.
ep = next((e for e in eps if e.get("id") == endpoint_id), None)
if ep:
await client.delete(
f"{internal_api_base()}/api/model-endpoints/{ep['id']}",
headers=_internal_headers(), headers=_internal_headers(),
) )
if response.status_code == 200:
logger.info( logger.info(
f"cookbook_serve_lifecycle: deleted endpoint {ep.get('id')} " "cookbook_serve_lifecycle: deleted endpoint %s for serve %s",
f"({ep.get('base_url')}) after scheduled stop" endpoint_id, serve_session_id,
) )
except Exception as e: elif response.status_code in (404, 409):
logger.warning(f"cookbook_serve_lifecycle: endpoint delete failed: {e}") logger.info(
"cookbook_serve_lifecycle: endpoint %s absent or reassigned; keeping it",
endpoint_id,
)
else:
logger.warning(
"cookbook_serve_lifecycle: endpoint %s deletion returned HTTP %s",
endpoint_id, response.status_code,
)
except Exception as exc:
logger.warning("cookbook_serve_lifecycle: cleanup failed: %s", exc)
async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "") -> bool: async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
+4 -1
View File
@@ -142,7 +142,10 @@ def _process_pdf(
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
temp_img_path = tmp.name temp_img_path = tmp.name
try: try:
img.image.save(temp_img_path, "PNG") # pypdf -> PIL image image = img.image
if image.mode == "CMYK":
image = image.convert("RGB")
image.save(temp_img_path, "PNG")
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner) ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
if ocr_text and "unavailable" not in ocr_text.lower(): if ocr_text and "unavailable" not in ocr_text.lower():
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}" pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
+19
View File
@@ -0,0 +1,19 @@
"""The shared model defaults used by Quick Edit's image operations."""
from src.settings import get_user_setting, load_settings
def editor_image_models(owner: str = "") -> dict[str, str]:
settings = load_settings()
generation = ""
edit_model = ""
if owner:
generation = str(get_user_setting("image_model", owner=owner, default="") or "").strip()
edit_model = str(get_user_setting("image_edit_model", owner=owner, default="") or "").strip()
if not generation:
generation = str(settings.get("image_model") or "").strip()
if not edit_model:
edit_model = str(settings.get("image_edit_model") or generation).strip()
return {
"generation": generation,
"edit": edit_model or generation,
}
+9
View File
@@ -40,6 +40,15 @@ def image_edit_size(model_id: str, width: int, height: int) -> str:
return f"{width}x{height}" return f"{width}x{height}"
def image_aspect_ratio(width: int, height: int) -> str:
"""Choose the nearest common Image API ratio, without distorting pixels."""
if width <= 0 or height <= 0:
raise ValueError("Image dimensions must be positive")
ratios = ((1, 1), (16, 9), (9, 16), (3, 2), (2, 3), (4, 3), (3, 4), (21, 9))
w, h = min(ratios, key=lambda ratio: abs(math.log((ratio[0] / ratio[1]) / (width / height))))
return f"{w}:{h}"
def looks_like_image_generation_model(model_id: str) -> bool: def looks_like_image_generation_model(model_id: str) -> bool:
"""Return True when a model id should use image generation routes. """Return True when a model id should use image generation routes.
+4 -1
View File
@@ -770,8 +770,11 @@ class McpManager:
qualified.add(f"mcp__{server_id}__{tool['name']}") qualified.add(f"mcp__{server_id}__{tool['name']}")
return disabled_map, qualified return disabled_map, qualified
def is_builtin(self, server_id: str) -> bool: @staticmethod
def is_builtin(server_id: str) -> bool:
"""Check if a server is a built-in (auto-registered) server.""" """Check if a server is a built-in (auto-registered) server."""
if not isinstance(server_id, str):
return False
return server_id.startswith("builtin_") or server_id in { return server_id.startswith("builtin_") or server_id in {
"image_gen", "image_gen",
"memory", "memory",
+63 -8
View File
@@ -76,9 +76,23 @@ _ERROR_DETAIL = {
"auth_or_protocol_error": "authentication or protocol error", "auth_or_protocol_error": "authentication or protocol error",
"no_models": "endpoint returned no models", "no_models": "endpoint returned no models",
"no_host": "no host configured", "no_host": "no host configured",
"config_source_error": "configuration source could not be read",
"error": "probe failed", "error": "probe failed",
} }
# Config/inventory source behind each network subsystem. `_gather_inputs()`
# fails soft, so a source that could not be read hands its probe an empty
# inventory — indistinguishable from "nothing configured", which every probe
# answers with `disabled`. The collector therefore reports the source failure
# instead of running a probe whose verdict would be a lie.
CONFIG_SOURCE_ERROR = "config_source_error"
_SUBSYSTEM_SOURCE = {
"searxng": "settings",
"ntfy": "integrations",
"email": "accounts",
"providers": "endpoints",
}
def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]: def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]:
return {"name": name, "status": status, "detail": detail, "meta": dict(meta)} return {"name": name, "status": status, "detail": detail, "meta": dict(meta)}
@@ -411,26 +425,33 @@ def _gather_inputs() -> Dict[str, Any]:
"""Pull live config/account/endpoint lists from the app's data sources. """Pull live config/account/endpoint lists from the app's data sources.
Each lookup fails soft: a broken source yields an empty/neutral value so a Each lookup fails soft: a broken source yields an empty/neutral value so a
single failure can't take down the whole health report. single failure can't take down the whole health report. The failure is also
*recorded* under `failed` (source -> controlled category), because the empty
value alone cannot be told apart from a source that loaded and is genuinely
empty — and the probes read empty as `disabled`.
""" """
settings: Dict[str, Any] = {} settings: Dict[str, Any] = {}
integrations: List[Dict[str, Any]] = [] integrations: List[Dict[str, Any]] = []
accounts: List[Dict[str, Any]] = [] accounts: List[Dict[str, Any]] = []
endpoints: List[Dict[str, Any]] = [] endpoints: List[Dict[str, Any]] = []
failed: Dict[str, str] = {}
try: try:
from src.settings import load_settings from src.settings import load_settings
settings = load_settings() or {} settings = load_settings() or {}
except Exception as e: except Exception as e:
failed["settings"] = _classify_error(e)
logger.debug(f"service_health: settings load failed: {e}") logger.debug(f"service_health: settings load failed: {e}")
try: try:
from src.integrations import load_integrations from src.integrations import load_integrations
integrations = load_integrations() or [] integrations = load_integrations() or []
except Exception as e: except Exception as e:
failed["integrations"] = _classify_error(e)
logger.debug(f"service_health: integrations load failed: {e}") logger.debug(f"service_health: integrations load failed: {e}")
try: try:
from routes.email_helpers import _list_email_accounts from routes.email_helpers import _list_email_accounts
accounts = _list_email_accounts() or [] accounts = _list_email_accounts() or []
except Exception as e: except Exception as e:
failed["accounts"] = _classify_error(e)
logger.debug(f"service_health: email accounts load failed: {e}") logger.debug(f"service_health: email accounts load failed: {e}")
try: try:
from core.database import SessionLocal, ModelEndpoint from core.database import SessionLocal, ModelEndpoint
@@ -443,9 +464,10 @@ def _gather_inputs() -> Dict[str, Any]:
finally: finally:
db.close() db.close()
except Exception as e: except Exception as e:
failed["endpoints"] = _classify_error(e)
logger.debug(f"service_health: endpoint load failed: {e}") logger.debug(f"service_health: endpoint load failed: {e}")
return {"settings": settings, "integrations": integrations, return {"settings": settings, "integrations": integrations,
"accounts": accounts, "endpoints": endpoints} "accounts": accounts, "endpoints": endpoints, "failed": failed}
async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]: async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
@@ -464,6 +486,24 @@ async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
return _svc(name, DOWN, _detail_for(category), error=category) return _svc(name, DOWN, _detail_for(category), error=category)
async def _source_unavailable(name: str, source: str,
category: str) -> Dict[str, Any]:
"""Controlled entry for a subsystem whose config source could not be read.
Deliberately not `disabled`: the configured state is *unknown*, not known to
be off. `degraded` rather than `down` because the failure is in reading the
inventory, not a probe that reached the subsystem and failed — but it still
carries non-zero severity, so a report containing one can never roll up to
`ok`. `meta` names the source and the controlled category of the underlying
exception; never its text.
"""
return _svc(name, DEGRADED,
f"Could not read configuration "
f"({_detail_for(CONFIG_SOURCE_ERROR)}).",
error=CONFIG_SOURCE_ERROR, source=source,
source_error=category)
async def collect_service_health(rag_manager: Any = None, async def collect_service_health(rag_manager: Any = None,
memory_vector: Any = None) -> Dict[str, Any]: memory_vector: Any = None) -> Dict[str, Any]:
"""Run every probe and return {overall, services, timestamp}. """Run every probe and return {overall, services, timestamp}.
@@ -472,6 +512,10 @@ async def collect_service_health(rag_manager: Any = None,
four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`, four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`,
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
providers/email are themselves bounded by `_FANOUT_BUDGET`. providers/email are themselves bounded by `_FANOUT_BUDGET`.
A subsystem whose config/inventory source failed to load is reported via
`_source_unavailable()` instead of being probed with an empty inventory,
which every probe would otherwise report as `disabled`.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -482,12 +526,23 @@ async def collect_service_health(rag_manager: Any = None,
chroma = chromadb_health(rag_manager, memory_vector) chroma = chromadb_health(rag_manager, memory_vector)
names = ["searxng", "ntfy", "email", "providers"] names = ["searxng", "ntfy", "email", "providers"]
coros = [ probes = {
_run_subsystem("searxng", searxng_health, settings), "searxng": (searxng_health, (settings,)),
_run_subsystem("ntfy", ntfy_health, inputs["integrations"], settings), "ntfy": (ntfy_health, (inputs["integrations"], settings)),
_run_subsystem("email", email_health, inputs["accounts"]), "email": (email_health, (inputs["accounts"],)),
_run_subsystem("providers", providers_health, inputs["endpoints"]), "providers": (providers_health, (inputs["endpoints"],)),
] }
# A subsystem whose inventory could not be read is reported as such. The
# others are still probed normally — one broken source degrades one entry.
failed = inputs.get("failed") or {}
coros = []
for name in names:
source = _SUBSYSTEM_SOURCE[name]
if source in failed:
coros.append(_source_unavailable(name, source, failed[source]))
else:
fn, args = probes[name]
coros.append(_run_subsystem(name, fn, *args))
try: try:
results = await asyncio.wait_for(asyncio.gather(*coros), results = await asyncio.wait_for(asyncio.gather(*coros),
timeout=_AGGREGATE_DEADLINE) timeout=_AGGREGATE_DEADLINE)
+33 -5
View File
@@ -57,25 +57,53 @@ def _message_to_context(msg: DBChatMessage) -> dict[str, Any]:
} }
def _escape_like(value: str) -> str: def escape_like_pattern(value: str) -> str:
"""Escape SQL LIKE wildcards (``%``, ``_``) and the escape char itself.
Public shared helper for building ``ilike(f"%{...}%", escape="\\\\")``
patterns from user input. Must escape backslash first.
"""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _escape_like(value: str) -> str:
return escape_like_pattern(value)
def _snippet(content: str, query: str, radius: int = 60) -> str: def _snippet(content: str, query: str, radius: int = 60) -> str:
content = content or "" content = content or ""
query = query or "" query = query or ""
if not query: if not query:
return content[: radius * 2] return content[: radius * 2]
idx = content.lower().find(query.lower()) match_start = -1
if idx == -1: match_length = 0
lowered_content = content.lower()
for candidate in _query_terms(query):
idx = lowered_content.find(candidate.lower())
if idx != -1 and (match_start == -1 or idx < match_start):
match_start = idx
match_length = len(candidate)
if match_start == -1:
return content[: radius * 2] return content[: radius * 2]
start = max(0, idx - radius) start = max(0, match_start - radius)
end = min(len(content), idx + len(query) + radius) end = min(len(content), match_start + match_length + radius)
return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "") return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
def _query_terms(query: str) -> list[str]:
"""Return meaningful quoted phrases and tokens in user-entered order."""
terms: list[str] = []
for match in re.finditer(r'"([^"]+)"|[\w][\w._-]*', query, flags=re.UNICODE):
term = match.group(1) if match.group(1) is not None else match.group(0).strip("._-")
term = term.strip()
if term:
terms.append(term)
return terms
def _sanitize_fts_query(query: str) -> str | None: def _sanitize_fts_query(query: str) -> str | None:
"""Convert free text into a conservative FTS5 MATCH query. """Convert free text into a conservative FTS5 MATCH query.
+17 -1
View File
@@ -46,6 +46,7 @@ DEFAULT_SETTINGS = {
"agent_email_confirm": True, "agent_email_confirm": True,
"image_gen_enabled": False, "image_gen_enabled": False,
"image_model": "", "image_model": "",
"image_edit_model": "",
"image_quality": "medium", "image_quality": "medium",
# Optional editor planner/caption endpoints. Empty uses configured chat # Optional editor planner/caption endpoints. Empty uses configured chat
# fallback without probing a developer-specific host. # fallback without probing a developer-specific host.
@@ -119,6 +120,7 @@ DEFAULT_SETTINGS = {
# Tune via Settings or by editing data/settings.json. # Tune via Settings or by editing data/settings.json.
"research_run_timeout_seconds": 1800, "research_run_timeout_seconds": 1800,
"agent_max_tool_calls": 0, "agent_max_tool_calls": 0,
"tool_approval_mode": "ask",
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200) "agent_max_rounds": 20, # per-message agent step cap (clamped 1..200)
# Soft input-token budget for the agent loop. The DEFAULT value (6000) is the # Soft input-token budget for the agent loop. The DEFAULT value (6000) is the
# "auto" sentinel: it means "scale the budget to the model's context window" # "auto" sentinel: it means "scale the budget to the model's context window"
@@ -294,7 +296,7 @@ def is_setting_overridden(key: str) -> bool:
# resolved by FastAPI deps; an empty/None owner falls through to the global. # resolved by FastAPI deps; an empty/None owner falls through to the global.
_PER_USER_KEYS = { _PER_USER_KEYS = {
"vision_model", "vision_enabled", "vision_model_fallbacks", "vision_model", "vision_enabled", "vision_model_fallbacks",
"image_model", "image_gen_enabled", "image_quality", "image_model", "image_edit_model", "image_gen_enabled", "image_quality",
# Default chat endpoint / model — without per-user resolution every new # Default chat endpoint / model — without per-user resolution every new
# account inherited whatever the most-recent admin picked, which then # account inherited whatever the most-recent admin picked, which then
# got injected into the chat composer on first open. # got injected into the chat composer on first open.
@@ -313,6 +315,20 @@ def get_user_setting(key: str, owner: str = "", default: Any = None) -> Any:
Falls back gracefully if the prefs module can't be imported (cycle/early Falls back gracefully if the prefs module can't be imported (cycle/early
boot) — admin-global settings keep working. boot) — admin-global settings keep working.
""" """
if key == "image_gen_enabled":
global_val = get_setting("image_gen_enabled", default if default is not None else False)
if not global_val:
return False
if owner:
try:
from routes.prefs_routes import _load_for_user
prefs = _load_for_user(owner) or {}
if "image_gen_enabled" in prefs and prefs["image_gen_enabled"] is not None:
return bool(prefs["image_gen_enabled"])
except Exception:
pass
return bool(global_val)
if owner and key in _PER_USER_KEYS: if owner and key in _PER_USER_KEYS:
try: try:
from routes.prefs_routes import _load_for_user from routes.prefs_routes import _load_for_user
+317
View File
@@ -334,6 +334,142 @@ _BROWSER_MCP_READ_TOOLS = frozenset(
) )
LOCAL_MCP_READ_TOOLS_ENV = "ODYSSEUS_LOCAL_MCP_READ_TOOLS"
LOCAL_MCP_WRITE_TOOLS_ENV = "ODYSSEUS_LOCAL_MCP_WRITE_TOOLS"
_LOCAL_MCP_READ_CAPABILITIES = _capabilities(
ToolEffect.READ_PRIVATE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_LOCAL_MCP_WRITE_CAPABILITIES = _capabilities(
ToolEffect.WRITE_PRIVATE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
def _local_mcp_tools(variable: str) -> frozenset[str]:
"""Tool names a deployment declares as touching only the local store.
Tools served by a user-configured MCP server are absent from the registry
above, so they are classified as unknown and fail high. For a server with
both a read and a write tool that is unusable: the read result arms the
post-external gate, because an unknown result is not SYSTEM integrity, and
the write that follows is refused until separately authorized. Every
second call needs an approval, for a server that never leaves the host.
Declaring the tools resolves that without widening the gate:
ODYSSEUS_LOCAL_MCP_READ_TOOLS=mcp__<server>__search_notes,mcp__<server>__read_note
ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=mcp__<server>__create_note
Read tools become READ_PRIVATE with EXTERNAL_UNTRUSTED integrity. Their
results remain untrusted and still arm the gate, preventing prompt
injections from reaching shell, network, or unrelated tools. However,
declared write tools on the exact same MCP server receive a narrowly
scoped continuation exception as long as no external or error taint has
entered the run. Once genuinely external content has entered the run,
approvals remain required.
"""
raw = os.getenv(variable, "") or ""
return frozenset(name.strip() for name in raw.split(",") if name.strip())
def _current_mcp_manager():
"""Retrieve the current McpManager instance safely without hard circular binds."""
try:
from src import tool_execution
if hasattr(tool_execution, "get_mcp_manager"):
mgr = tool_execution.get_mcp_manager()
if mgr is not None:
return mgr
except Exception:
pass
try:
from src.tool_utils import get_mcp_manager
return get_mcp_manager()
except Exception:
return None
def _is_declared_local_mcp_tool(
tool_name: Any, server_id: str | None = None
) -> bool:
"""Return whether tool_name is an exact, well-formed declared local MCP tool."""
if not isinstance(tool_name, str) or not tool_name:
return False
parts = tool_name.split("__", 2)
if not (
len(parts) == 3
and parts[0] == "mcp"
and bool(parts[1])
and bool(parts[2])
and not any(char.isspace() for part in parts[1:] for char in part)
):
return False
sid = parts[1]
if server_id is not None and sid != server_id:
return False
from src.mcp_manager import McpManager
if McpManager.is_builtin(sid):
return False
if tool_name in TOOL_CAPABILITIES:
return False
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
return (declared_read or declared_write) and (declared_read != declared_write)
def _is_declared_local_mcp_read_tool(
tool_name: Any, server_id: str | None = None
) -> bool:
if not _is_declared_local_mcp_tool(tool_name, server_id):
return False
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
if not (declared_read and not declared_write):
return False
parts = tool_name.split("__", 2)
mgr = _current_mcp_manager()
if mgr is not None:
tools = mgr._tools.get(parts[1], [])
tool_row = next((r for r in tools if r.get("name") == parts[2]), None)
if tool_row is not None:
from src.mcp_manager import mcp_tool_is_readonly
if not mcp_tool_is_readonly(tool_row):
return False
else:
from src.mcp_manager import _MCP_READONLY_VERBS
if not parts[2].lower().startswith(_MCP_READONLY_VERBS):
return False
return True
def _is_declared_local_mcp_write_tool(
tool_name: Any, server_id: str | None = None
) -> bool:
if not _is_declared_local_mcp_tool(tool_name, server_id):
return False
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
if not (declared_write and not declared_read):
return False
parts = tool_name.split("__", 2)
mgr = _current_mcp_manager()
if mgr is not None:
tools = mgr._tools.get(parts[1], [])
tool_row = next((r for r in tools if r.get("name") == parts[2]), None)
if tool_row is not None:
ann = tool_row.get("annotations") or {}
destructive = (
ann.get("destructiveHint")
if isinstance(ann, dict)
else getattr(ann, "destructiveHint", None)
)
if destructive is True:
return False
return True
def capabilities_for_tool(tool_name: Any) -> ToolCapabilities: def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
"""Return deterministic capabilities; malformed and unknown tools fail high.""" """Return deterministic capabilities; malformed and unknown tools fail high."""
if not isinstance(tool_name, str) or not tool_name: if not isinstance(tool_name, str) or not tool_name:
@@ -348,6 +484,10 @@ def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
return capabilities return capabilities
if tool_name in _BROWSER_MCP_READ_TOOLS: if tool_name in _BROWSER_MCP_READ_TOOLS:
return _BROWSER_MCP_READ_CAPABILITIES return _BROWSER_MCP_READ_CAPABILITIES
if _is_declared_local_mcp_read_tool(tool_name):
return _LOCAL_MCP_READ_CAPABILITIES
if _is_declared_local_mcp_write_tool(tool_name):
return _LOCAL_MCP_WRITE_CAPABILITIES
return _UNKNOWN_CAPABILITIES return _UNKNOWN_CAPABILITIES
@@ -785,6 +925,149 @@ def delegated_tool_is_blocked(tool_name: Any, content: Any = None) -> bool:
) )
_SERVER_OWNED_CONTROL_PLANE_SOURCES = frozenset(
{
"skills",
"client runtime context",
"backend runtime context",
"integrations",
"mcp tools",
"agents.md",
}
)
_PROHIBITED_CONTINUATION_EFFECTS = frozenset(
{
ToolEffect.NETWORK_EGRESS,
ToolEffect.EXTERNAL_SIDE_EFFECT,
ToolEffect.EXECUTE_CODE,
ToolEffect.ADMIN_CHANGE,
ToolEffect.UI_SIDE_EFFECT,
ToolEffect.DESTRUCTIVE,
ToolEffect.READ_WORKSPACE,
ToolEffect.WRITE_WORKSPACE,
ToolEffect.BROKERED_NETWORK_READ,
ToolEffect.READ_PUBLIC,
}
)
def _is_authorized_local_mcp_continuation(
tool_name: Any,
external_sources: Iterable[str],
content: Any = None,
) -> bool:
"""Evaluate narrowly scoped continuation for declared local MCP tools.
Allows a declared local MCP write or read tool when the tool is verified
against an explicitly configured and connected stdio MCP server, and the
only untrusted sources observed in the run are declared local MCP tools
belonging to the exact same MCP server (plus server-owned control plane
metadata).
"""
if not isinstance(tool_name, str) or not tool_name:
return False
parts = tool_name.split("__", 2)
if len(parts) != 3 or parts[0] != "mcp" or not parts[1] or not parts[2]:
return False
server_id = parts[1]
from src.mcp_manager import McpManager
if McpManager.is_builtin(server_id):
return False
if not _is_declared_local_mcp_tool(tool_name, server_id=server_id):
return False
capabilities = capabilities_for_action(tool_name, content)
if not capabilities.known:
return False
if capabilities.effects & _PROHIBITED_CONTINUATION_EFFECTS:
return False
if capabilities.effects - {ToolEffect.WRITE_PRIVATE, ToolEffect.READ_PRIVATE}:
return False
manager = _current_mcp_manager()
if manager is None:
return False
if manager.is_builtin(server_id):
return False
conn = manager.get_server_status(server_id)
if conn.get("status") != "connected":
return False
if conn.get("transport") != "stdio":
return False
resource_id = manager.resource_identity(tool_name)
if resource_id is None or resource_id.namespace != "mcp":
return False
if resource_id.endpoint_id != f"stdio:{server_id}":
return False
tools_list = manager._tools.get(server_id, [])
tool_row = next((r for r in tools_list if r.get("name") == parts[2]), None)
if not tool_row:
return False
ann = tool_row.get("annotations") or {}
destructive = (
ann.get("destructiveHint")
if isinstance(ann, dict)
else getattr(ann, "destructiveHint", None)
)
if destructive is True:
return False
from src.agent_runtime.authority import active_request_authority, ExactOperation, RequestAuthority
authority = active_request_authority()
if not isinstance(authority, RequestAuthority):
return False
if authority is not None:
try:
operation_input = content if isinstance(content, str) else "{}"
operation = ExactOperation.normalize(tool_name, operation_input)
if not authority.permits(operation):
return False
except (ValueError, TypeError):
return False
if resource_id not in authority.backend_resources:
return False
sources = list(external_sources or ())
if not sources:
return False
from src.mcp_manager import mcp_tool_is_readonly
has_local_tool = False
for source in sources:
if source in _SERVER_OWNED_CONTROL_PLANE_SOURCES:
continue
if not _is_declared_local_mcp_tool(source, server_id=server_id):
return False
source_caps = capabilities_for_tool(source)
if not source_caps.known or (source_caps.effects & _PROHIBITED_CONTINUATION_EFFECTS):
return False
if source_caps.effects - {ToolEffect.READ_PRIVATE, ToolEffect.WRITE_PRIVATE}:
return False
source_resource = manager.resource_identity(source)
if source_resource is None or source_resource.endpoint_id != resource_id.endpoint_id:
return False
if not str(source_resource.endpoint_id).startswith("stdio:"):
return False
source_parts = source.split("__", 2)
source_row = next((r for r in tools_list if r.get("name") == source_parts[2]), None)
if not source_row:
return False
if _is_declared_local_mcp_read_tool(source, server_id=server_id) and not mcp_tool_is_readonly(source_row):
return False
has_local_tool = True
return has_local_tool
@dataclass @dataclass
class ToolRunSecurityContext: class ToolRunSecurityContext:
"""Server-owned integrity state for one agent run.""" """Server-owned integrity state for one agent run."""
@@ -804,6 +1087,19 @@ class ToolRunSecurityContext:
# Driven by a bearer API token, not a person at a browser. Privileged # Driven by a bearer API token, not a person at a browser. Privileged
# tools are refused outright and no approval can lift that. # tools are refused outright and no approval can lift that.
delegated_credential: bool = False delegated_credential: bool = False
explicit_untrusted_seen: bool = False
prompt_external_untrusted_seen: bool = False
def __post_init__(self) -> None:
if self.external_untrusted_context_seen and (
not self.external_sources
or any(
not _is_declared_local_mcp_tool(s)
and s not in _SERVER_OWNED_CONTROL_PLANE_SOURCES
for s in self.external_sources
)
):
self.prompt_external_untrusted_seen = True
def observe_messages(self, messages: Iterable[dict]) -> None: def observe_messages(self, messages: Iterable[dict]) -> None:
"""Apply server-owned chat scope and promote untrusted prompt context.""" """Apply server-owned chat scope and promote untrusted prompt context."""
@@ -834,8 +1130,10 @@ class ToolRunSecurityContext:
if source and source not in self.external_sources: if source and source not in self.external_sources:
self.external_sources.append(source) self.external_sources.append(source)
self.external_untrusted_context_seen = True self.external_untrusted_context_seen = True
self.prompt_external_untrusted_seen = True
if messages_contain_external_untrusted_context(message_list): if messages_contain_external_untrusted_context(message_list):
self.external_untrusted_context_seen = True self.external_untrusted_context_seen = True
self.prompt_external_untrusted_seen = True
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision: def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
# Checked before the bypasses below, because neither may lift it, and # Checked before the bypasses below, because neither may lift it, and
@@ -858,6 +1156,20 @@ class ToolRunSecurityContext:
if not self.external_untrusted_context_seen: if not self.external_untrusted_context_seen:
return ToolGateDecision(True) return ToolGateDecision(True)
# Scoped continuation exception for explicitly declared local MCP tools:
# A declared write or read tool on the same MCP server is allowed if
# the run's only untrusted context was introduced by declared local
# tools on that server, without any explicit/error taint or external
# message context.
if (
not self.prompt_external_untrusted_seen
and not self.explicit_untrusted_seen
and _is_authorized_local_mcp_continuation(
tool_name, self.external_sources, content=content
)
):
return ToolGateDecision(True)
# Skills and other server-owned descriptors are control-plane metadata # Skills and other server-owned descriptors are control-plane metadata
# rather than external result content; web/document/tool-result taint # rather than external result content; web/document/tool-result taint
# still gates actions that can mutate state or execute code. # still gates actions that can mutate state or execute code.
@@ -906,6 +1218,11 @@ class ToolRunSecurityContext:
if not tool_result_should_arm_gate(tool_name, result, content): if not tool_result_should_arm_gate(tool_name, result, content):
return return
self.external_untrusted_context_seen = True self.external_untrusted_context_seen = True
if isinstance(result, dict) and (
result.get("untrusted_content") is True
or not tool_result_is_successful(result)
):
self.explicit_untrusted_seen = True
if isinstance(tool_name, str) and tool_name not in self.external_sources: if isinstance(tool_name, str) and tool_name not in self.external_sources:
self.external_sources.append(tool_name) self.external_sources.append(tool_name)
+7
View File
@@ -1917,6 +1917,13 @@ async def execute_tool_block(
) )
if isinstance(security_context, ToolRunSecurityContext) and not approval_claimed: if isinstance(security_context, ToolRunSecurityContext) and not approval_claimed:
if isinstance(authority, RequestAuthority):
with bind_request_authority(authority):
decision = security_context.decision_for(
getattr(block, "tool_type", None),
getattr(block, "content", None),
)
else:
decision = security_context.decision_for( decision = security_context.decision_for(
getattr(block, "tool_type", None), getattr(block, "tool_type", None),
getattr(block, "content", None), getattr(block, "content", None),
+6
View File
@@ -6440,6 +6440,12 @@ def resolve_turn_contract(*, capabilities: Iterable[str], schemas: Iterable[dict
if 'search_emails' in selected: if 'search_emails' in selected:
selected.update({'read_email', 'download_attachment', 'list_email_accounts'}) selected.update({'read_email', 'download_attachment', 'list_email_accounts'})
selected.update(canonical_tool(n) for n in always_available_tools) selected.update(canonical_tool(n) for n in always_available_tools)
# Offer only dynamic MCP operations already sealed by trusted request
# admission. Tool discovery and model routing never create these grants.
from src.agent_runtime.authority import active_request_authority
sealed_authority = active_request_authority()
if sealed_authority is not None:
selected.update(g.tool for g in sealed_authority.grants if g.tool.startswith("mcp__"))
selected.update(canonical_tool(n) for n in warm_tools if str(n or "").strip()) selected.update(canonical_tool(n) for n in warm_tools if str(n or "").strip())
# Controls are neutral; enabling Web is permission, never a requested family. # Controls are neutral; enabling Web is permission, never a requested family.
if selected: if selected:
+39
View File
@@ -52,6 +52,45 @@
gap: 8px; gap: 8px;
} }
.admin-user-name { font-size: 13px; font-weight: 500; } .admin-user-name { font-size: 13px; font-weight: 500; }
/* Inline endpoint rename: the input keeps the name's typography so the row doesn't shift */
.adm-ep-rename-btn {
background: none;
border: none;
padding: 0 2px;
color: inherit;
line-height: 1;
cursor: pointer;
opacity: 0.45;
}
.adm-ep-rename-btn:hover,
.adm-ep-rename-btn:focus-visible { opacity: 0.9; }
@media (hover: hover) {
.adm-ep-rename-btn { opacity: 0; }
.admin-user-row:hover .adm-ep-rename-btn { opacity: 0.45; }
.admin-user-row .adm-ep-rename-btn:hover,
.adm-ep-rename-btn:focus-visible { opacity: 0.9; }
}
.admin-user-row input.adm-ep-rename-input {
width: auto;
field-sizing: content;
max-width: 100%;
height: auto;
padding: 0 0 1px;
line-height: inherit;
color: inherit;
background: transparent;
border: none;
border-bottom: 1px solid var(--red);
border-radius: 0;
outline: none;
}
.admin-user-info:has(.adm-ep-name, .adm-ep-rename-input) { min-width: 0; }
.adm-ep-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Privilege panel */ /* Privilege panel */
.admin-priv-panel { .admin-priv-panel {
max-height: 600px; max-height: 600px;
+63 -1
View File
@@ -264,7 +264,42 @@
/* The brush controls (Color/Size/preset/caret) hug their content; only the /* The brush controls (Color/Size/preset/caret) hug their content; only the
trailing slider section grows, so the Opacity/Flow/Softness group sits trailing slider section grows, so the Opacity/Flow/Softness group sits
right after the caret instead of leaving a wide gap. */ right after the caret instead of leaving a wide gap. */
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; } .ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; flex-direction: row; align-items: center; gap: 8px; }
.ge-controls--in-options #ge-brush-softness-row input[type="range"] { width: 88px; flex: 0 0 88px; }
#ge-brush-softness-row {
display: grid !important;
grid-template-columns: 14px max-content 88px;
column-gap: 8px;
align-items: center;
}
#ge-brush-softness-row:has(> .ge-slider-value) {
grid-template-columns: 14px max-content 88px 6ch;
}
#ge-brush-softness-row label {
flex: 0 0 auto;
min-width: max-content;
gap: 6px;
}
#ge-brush-softness-row #ge-brush-softness-label {
position: static;
min-width: 6ch;
margin: 0;
padding: 0;
white-space: nowrap;
text-align: right;
font-variant-numeric: tabular-nums;
}
.ge-controls--in-options #ge-brush-controls .ge-brush-size-row {
display: grid;
grid-template-columns: minmax(6ch, max-content) 96px auto;
column-gap: 8px;
}
.ge-controls--in-options .ge-brush-size-row label,
.ge-controls--in-options .ge-brush-size-row .ge-size-label {
min-width: 6ch;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ge-controls--in-options .ge-control-row { .ge-controls--in-options .ge-control-row {
margin: 0 !important; margin: 0 !important;
flex: 0 0 auto; flex: 0 0 auto;
@@ -4607,6 +4642,8 @@ body.ge-editor-open textarea.ge-direct-text-editor {
pointer-events: auto; pointer-events: auto;
} }
.ge-filter-modal { .ge-filter-modal {
max-height: calc(100dvh - 40px);
overflow-y: auto;
width: 240px; width: 240px;
background: color-mix(in srgb, var(--panel) 95%, transparent); background: color-mix(in srgb, var(--panel) 95%, transparent);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -4631,6 +4668,9 @@ body.ge-editor-open textarea.ge-direct-text-editor {
gap: 2px; gap: 2px;
} }
.ge-filter-row label { .ge-filter-row label {
flex: 0 0 auto;
min-width: 0;
width: 100%;
font-size: 10px; font-size: 10px;
opacity: 0.7; opacity: 0.7;
display: flex; display: flex;
@@ -9732,6 +9772,28 @@ body.ge-editor-open .tour-hint {
margin-left: auto; margin-left: auto;
color: var(--color-muted); color: var(--color-muted);
} }
.ge-submenu .dropdown-item-compact {
box-sizing: border-box;
display: flex;
align-items: center;
height: 28px;
min-height: 28px;
max-height: 28px;
padding: 0 10px;
line-height: 1.2;
white-space: nowrap;
}
.ge-submenu .dropdown-item-compact svg {
flex-shrink: 0;
max-height: 16px;
}
@media (max-width: 820px) {
.ge-submenu .dropdown-item-compact {
height: 32px;
min-height: 32px;
max-height: 32px;
}
}
.ge-submenu { .ge-submenu {
display: none; display: none;
position: absolute; position: absolute;
+7
View File
@@ -339,6 +339,13 @@ mark.doc-find-mark {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
/* The container now fills the body exactly, so its inline 2px top margin
(spacing for the grid view) would push it 2px past the body and add a
second, outer scrollbar beside the inspector's own. !important is needed
to beat the inline style. */
.gallery-images-container:has(> #gallery-detail[style*="flex"]) {
margin-top: 0 !important;
}
} }
.gallery-detail-image:hover .gallery-detail-rotate { opacity: 0.85; } .gallery-detail-image:hover .gallery-detail-rotate { opacity: 0.85; }
.gallery-detail-nav-disabled { .gallery-detail-nav-disabled {
+43 -23
View File
@@ -247,32 +247,32 @@
real request is discarded and the font fetched a second time. --> real request is discarded and the font fetched a second time. -->
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2"> <link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2"> <link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/00-tokens.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/02-compare.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/04-memory.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/05-documents.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/07-documents.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/07-documents.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/08-skills.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/08-skills.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/09-gallery.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/10-cookbook.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/11-tasks.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/12-gallery.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/13-image-editor.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/14-email.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/14-email.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/15-notes.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/15-notes.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/16-calendar.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/17-research.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/17-research.css?v=20261007detailscroll1">
<!-- Panel stylesheets split out of style.css for maintainability. They are <!-- Panel stylesheets split out of style.css for maintainability. They are
eager and their order is load-bearing: the cascade is the concatenation of eager and their order is load-bearing: the cascade is the concatenation of
these files in this order, so they must stay in this order here and in the these files in this order, so they must stay in this order here and in the
PRECACHE list in sw.js. None of them is lazy-loaded. --> PRECACHE list in sw.js. None of them is lazy-loaded. -->
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260929csssplit"> <link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20261007detailscroll1">
<link rel="modulepreload" href="/static/app.js?v=20260929csssplit"> <link rel="modulepreload" href="/static/app.js?v=20261007detailscroll1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1"> <link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1"> <link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
<link rel="modulepreload" href="/static/js/sessions.js"> <link rel="modulepreload" href="/static/js/sessions.js">
@@ -1677,6 +1677,10 @@
<label class="settings-label">Model</label> <label class="settings-label">Model</label>
<select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select> <select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
</div> </div>
<div style="display:flex;align-items:center;gap:0.75rem;">
<label class="settings-label" for="set-imgEditModelSelect">Image edit model</label>
<select id="set-imgEditModelSelect" class="settings-select"><option value="">Same as generation</option></select>
</div>
<div style="display:flex;align-items:center;gap:0.75rem;"> <div style="display:flex;align-items:center;gap:0.75rem;">
<label class="settings-label">Quality</label> <label class="settings-label">Quality</label>
<select id="set-imgQualitySelect" class="settings-select"> <select id="set-imgQualitySelect" class="settings-select">
@@ -2459,6 +2463,22 @@
<label class="settings-label">Max steps per message</label> <label class="settings-label">Max steps per message</label>
<input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;"> <input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;">
</div> </div>
<div class="settings-row">
<label class="settings-label">Tool approval</label>
<select id="set-toolApprovalMode" class="settings-select">
<option value="ask">Always ask</option>
<option value="off">Off (YOLO)</option>
</select>
</div>
<div class="admin-toggle-sub" style="margin-top:4px;">
Auto-approve turns off the tool approval gate (same control as the
ODYSSEUS_TOOL_APPROVAL_GATE env var). Bash, host_shell, send_email and
delete_email then run without a confirmation even after the agent has
seen untrusted external content (email, web pages, documents), so a
prompt-injected instruction can reach them directly.
</div>
<div id="set-toolApprovalEnvNote" class="admin-toggle-sub" style="margin-top:4px;display:none;color:var(--red);"></div>
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div> <div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div> </div>
</div> </div>
@@ -2689,7 +2709,7 @@
<script type="module" src="/static/js/censor.js"></script> <script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script> <script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script> <script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
<script type="module" src="/static/app.js?v=20260929csssplit"></script> <!-- app.js must be LAST --> <script type="module" src="/static/app.js?v=20261007detailscroll1"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script> <script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
<script type="module" src="/static/js/a11y.js"></script> <script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script> <script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
+66 -1
View File
@@ -1074,7 +1074,8 @@ async function loadEndpoints() {
<span class="adm-check-dot adm-endpoint-select-dot" aria-hidden="true"></span> <span class="adm-check-dot adm-endpoint-select-dot" aria-hidden="true"></span>
</label>` : ''} </label>` : ''}
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span> <span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
<span class="admin-user-name">${esc(epTitle)}</span> <span class="admin-user-name adm-ep-name" title="${esc(epTitle)}">${esc(epTitle)}</span>
${_endpointSelectMode || (isChatgptAccount && ep.account_label) ? '' : `<button type="button" class="adm-ep-rename-btn" data-adm-rename-ep="${esc(ep.id)}" data-adm-ep-title="${esc(epTitle)}" title="Rename" aria-label="Rename endpoint"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>`}
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''} ${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''} ${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
${statusBadge} ${statusBadge}
@@ -1204,6 +1205,70 @@ async function loadEndpoints() {
loadEndpoints(); loadEndpoints();
}); });
}); });
queryAll('[data-adm-rename-ep]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const nameEl = btn.previousElementSibling;
if (!nameEl || !nameEl.classList.contains('admin-user-name')) return;
const original = btn.dataset.admEpTitle || '';
const input = document.createElement('input');
input.type = 'text';
input.value = original;
input.placeholder = 'Endpoint name';
input.setAttribute('aria-label', 'Endpoint name');
input.maxLength = 64;
input.autocomplete = 'off';
input.setAttribute('data-bwignore', '');
input.className = 'admin-user-name adm-ep-rename-input';
input.style.minWidth = `min(${nameEl.getBoundingClientRect().width}px, 100%)`;
nameEl.replaceWith(input);
btn.style.visibility = 'hidden';
input.focus();
input.select();
let done = false;
const restore = () => {
input.replaceWith(nameEl);
btn.style.visibility = '';
};
const commit = async () => {
if (done) return;
done = true;
const name = input.value.trim();
if (!name || name === original) { restore(); return; }
const epId = btn.dataset.admRenameEp;
if (data.some(ep => String(ep.id) !== epId && (ep.name || '').trim().toLowerCase() === name.toLowerCase())) {
uiModule.showError(`An endpoint named "${name}" already exists`);
done = false;
input.focus();
return;
}
input.disabled = true;
try {
const res = await fetch(`/api/model-endpoints/${encodeURIComponent(epId)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to rename endpoint');
}
} catch (err) {
uiModule.showError(err.message || 'Failed to rename endpoint');
restore();
return;
}
await _refreshAfterEndpointChange();
loadEndpoints();
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done = true; restore(); }
});
});
});
queryAll('[data-adm-copy-url]').forEach(btn => { queryAll('[data-adm-copy-url]').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
+160 -51
View File
@@ -336,6 +336,9 @@ import { invalidateSettings } from './appConfig.js';
let _sendInFlight = false; // covers the window from click → streaming start let _sendInFlight = false; // covers the window from click → streaming start
let _displayOverride = null; // Override visible user bubble text (hides injected prompts) let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop) let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
// The user bubble a regenerate keeps on screen. Its resubmitted message is
// a new DB row, so the saved id is attached here.
let _regenKeptUserBubble = null;
let _contextHeaderSeq = 0; let _contextHeaderSeq = 0;
let _contextHeaderData = null; let _contextHeaderData = null;
let _contextHeaderBound = false; let _contextHeaderBound = false;
@@ -4995,6 +4998,15 @@ import { invalidateSettings } from './appConfig.js';
refreshChatContextHeader('metrics'); refreshChatContextHeader('metrics');
} }
} else if (json.type === 'user_message_saved') {
// The user bubble gets its DB id too, so regenerating or
// editing it later cuts the chat by id, not page position.
if (!_isBg && json.id) {
if (_userMsgEl) _userMsgEl.dataset.dbId = json.id;
else if (_regenKeptUserBubble) _regenKeptUserBubble.dataset.dbId = json.id;
_regenKeptUserBubble = null;
}
} else if (json.type === 'message_saved') { } else if (json.type === 'message_saved') {
// Wire the persisted DB id onto the just-streamed bubble so it // Wire the persisted DB id onto the just-streamed bubble so it
// can be edited/deleted immediately, without reloading the chat. // can be edited/deleted immediately, without reloading the chat.
@@ -7236,6 +7248,22 @@ import { invalidateSettings } from './appConfig.js';
/** /**
* Edit a user message: show an input, truncate to before it, resubmit the edited text. * Edit a user message: show an input, truncate to before it, resubmit the edited text.
*/ */
// Recover attached file IDs from rendered user bubbles, including older
// image previews that were rendered before data-file-id was stamped.
function _userMessageAttachmentIds(msgElement) {
if (!msgElement) return [];
const els = msgElement.querySelectorAll('[data-file-id]');
const ids = Array.from(els).map(el => el.dataset.fileId).filter(Boolean);
if (!ids.length) {
const images = msgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
for (const image of images) {
const match = (image.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
if (match && match[1] && !ids.includes(match[1])) ids.push(match[1]);
}
}
return ids;
}
export async function editUserMessage(userMsgElement) { export async function editUserMessage(userMsgElement) {
const box = document.getElementById('chat-history'); const box = document.getElementById('chat-history');
const allMsgs = Array.from(box.querySelectorAll('.msg')); const allMsgs = Array.from(box.querySelectorAll('.msg'));
@@ -7283,23 +7311,49 @@ import { invalidateSettings } from './appConfig.js';
e.stopPropagation(); e.stopPropagation();
const newText = editor.value.trim(); const newText = editor.value.trim();
if (!newText) return; if (!newText) return;
const editAttachmentIds = _userMessageAttachmentIds(userMsgElement);
const sessionId = sessionModule.getCurrentSessionId(); const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return; if (!sessionId) return;
const keepCount = msgIndex; // Everything after the original reply to this message would also be
// destroyed. Cancelling leaves the editor open with the draft intact.
const extraCount = allMsgs.length - msgIndex - 2;
const choice = await _confirmTruncateIfDestructive(extraCount, 'edit');
if (!choice) return;
if (choice === 'fork') {
// Branch just before this user turn and send the edit there; this
// chat keeps the original message and everything after it.
try { try {
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', newText, editAttachmentIds);
bodyEl.innerHTML = originalHTML;
} catch (err) {
console.error('Fork failed:', err);
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
}
return;
}
const keepCount = msgIndex;
// Cut at the message's database id: msgIndex counts only the messages
// loaded in the page, so it is too small when older history has not
// been scrolled in yet.
const editBeforeId = userMsgElement.dataset.dbId || '';
try {
const editTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount }) body: JSON.stringify(editBeforeId ? { before_msg_id: editBeforeId } : { keep_count: keepCount })
}); });
if (!editTruncate.ok) throw new Error('Server error ' + editTruncate.status);
// Remove DOM elements from msgIndex onward // Remove DOM elements from msgIndex onward
for (let i = allMsgs.length - 1; i >= msgIndex; i--) { for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
allMsgs[i].remove(); allMsgs[i].remove();
} }
// The edited user turn still refers to its original attachments.
_pendingRegenAttachments = editAttachmentIds;
// Submit the edited text // Submit the edited text
const messageInput = uiModule.el('message'); const messageInput = uiModule.el('message');
messageInput.value = newText; messageInput.value = newText;
@@ -7323,6 +7377,59 @@ import { invalidateSettings } from './appConfig.js';
}); });
} }
// Regenerate, resend and edit permanently delete (server-side truncate)
// everything after the point clicked. Ask first unless nothing comes after
// it (extraCount 0), offering a fork that leaves this chat untouched.
// Resolves to 'proceed', 'fork', or null (cancelled).
const _TRUNCATE_ACTIONS = {
regenerate: { lead: 'Regenerating here', confirmText: 'Delete and regenerate' },
resend: { lead: 'Resending this message', confirmText: 'Delete and resend' },
edit: { lead: 'Sending this edit', confirmText: 'Delete and send' },
};
async function _confirmTruncateIfDestructive(extraCount, action = 'regenerate') {
if (extraCount <= 0) return 'proceed';
const { lead, confirmText } = _TRUNCATE_ACTIONS[action] || _TRUNCATE_ACTIONS.regenerate;
const result = await uiModule.styledConfirm(
`${lead} will also permanently delete the ${extraCount} ` +
`message${extraCount === 1 ? '' : 's'} after it. This can't be undone. ` +
`Fork from here keeps this chat as it is and continues in a new one instead.`,
{ confirmText, alternateText: 'Fork from here', cancelText: 'Cancel', danger: true }
);
if (result === 'alternate') return 'fork';
return result ? 'proceed' : null;
}
// Copy the first `keepCount` messages of the current chat into a new chat
// and switch to it. Returns the new session id; throws on failure so callers
// that replay an action in the fork can stop before sending anything.
// Fork by message id when the element has one (before it, or through it);
// keepCount is only a fallback, since it counts loaded messages only.
async function _forkSessionAt(keepCount, beforeMsgId = '', throughMsgId = '') {
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) throw new Error('No active chat');
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount,
...(beforeMsgId ? { before_msg_id: beforeMsgId } : throughMsgId ? { through_msg_id: throughMsgId } : {}) }),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
await sessionModule.loadSessions();
await sessionModule.selectSession(data.id);
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
return data.id;
}
// Fork before a user message and send `text` there; this chat is untouched.
// `attachments` (if given) are re-sent only once the fork has succeeded.
async function _forkAndSend(keepCount, beforeMsgId, text, attachments) {
await _forkSessionAt(keepCount, beforeMsgId);
if (attachments !== undefined) _pendingRegenAttachments = attachments;
uiModule.el('message').value = text;
document.querySelector('.send-btn')?.click();
}
/** /**
* Resend a user message. By default this replaces from the selected user turn * Resend a user message. By default this replaces from the selected user turn
* so failed attempts do not remain in model context or SFT traces. Callers can * so failed attempts do not remain in model context or SFT traces. Callers can
@@ -7344,18 +7451,7 @@ import { invalidateSettings } from './appConfig.js';
let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim(); let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, ''); text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
// Collect file_ids attached to this user message so the resend re-carries const _ids = _userMessageAttachmentIds(userMsgElement);
// the photos / docs (and the chat handler picks up the user-edited OCR
// text cached server-side under those file ids).
const _attachEls = userMsgElement.querySelectorAll('[data-file-id]');
let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
if (!_ids.length) {
const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
for (const _im of _imgs) {
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]);
}
}
// Rescue: legacy bubbles may have stored the filename as the message // Rescue: legacy bubbles may have stored the filename as the message
// content (artifact of earlier broken resends). Don't re-send that as // content (artifact of earlier broken resends). Don't re-send that as
@@ -7378,6 +7474,18 @@ import { invalidateSettings } from './appConfig.js';
try { try {
if (replaceFromHere) { if (replaceFromHere) {
// Everything strictly after the immediate AI reply to this user
// message would also be destroyed — that's the part a click on
// Resend / "Regenerate message" doesn't expect to lose.
const extraCount = allMsgs.length - msgIndex - 2;
const choice = await _confirmTruncateIfDestructive(extraCount, 'resend');
if (!choice) return;
if (choice === 'fork') {
// Branch just before this user turn and send it again there; this
// chat keeps every message.
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', text, _ids);
return;
}
// Resend/regenerate trims history to this point before resubmitting so // Resend/regenerate trims history to this point before resubmitting so
// the replacement request is the only copy the backend sees. // the replacement request is the only copy the backend sees.
const keepCount = msgIndex; const keepCount = msgIndex;
@@ -7442,25 +7550,14 @@ import { invalidateSettings } from './appConfig.js';
return; return;
} }
// Collect any file_ids attached to the original user message so the // Everything strictly after the AI reply being regenerated would also
// regenerated send re-uses them. Without this the AI is regenerated on // be destroyed — that's the part a click on "Regenerate from here"
// text alone — photos (and the user-edited OCR text cached server-side // doesn't expect to lose.
// under that file_id) would be silently dropped. const extraCount = allMsgs.length - aiIndex - 1;
const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : []; const choice = await _confirmTruncateIfDestructive(extraCount, 'regenerate');
let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean); if (!choice) return;
// Fallback for bubbles rendered before the data-file-id stamp landed:
// sniff the file id straight out of any `.attach-image-preview img` const _regenIds = _userMessageAttachmentIds(userMsgEl);
// src URLs (matches /api/upload/<id>). Otherwise an older bubble would
// regen with zero attachments and the photo would be lost from the
// resulting message even though the file still exists on disk.
if (!_regenIds.length && userMsgEl) {
const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img');
for (const _im of _imgs) {
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]);
}
}
_pendingRegenAttachments = _regenIds;
// Rescue: earlier-version regens (before the dataset.raw fix) stored the // Rescue: earlier-version regens (before the dataset.raw fix) stored the
// photo's filename as the user-message content. On a follow-up regen, // photo's filename as the user-message content. On a follow-up regen,
@@ -7469,7 +7566,7 @@ import { invalidateSettings } from './appConfig.js';
// an image file". If userText is just a bare image filename and we have // an image file". If userText is just a bare image filename and we have
// attachments, drop it so the OCR text (or the image bytes for vision // attachments, drop it so the OCR text (or the image bytes for vision
// models) is what the model actually sees. // models) is what the model actually sees.
if (userText && _pendingRegenAttachments.length && if (userText && _regenIds.length &&
/^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) { /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
userText = ''; userText = '';
} }
@@ -7477,7 +7574,7 @@ import { invalidateSettings } from './appConfig.js';
// A photo-only message has empty user text — regen must still proceed, // A photo-only message has empty user text — regen must still proceed,
// because the attachments themselves are the message. Bail only if there // because the attachments themselves are the message. Bail only if there
// is no text AND no attachments to send. // is no text AND no attachments to send.
if (!userText && !_pendingRegenAttachments.length) { if (!userText && !_regenIds.length) {
if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments'); if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
return; return;
} }
@@ -7485,6 +7582,20 @@ import { invalidateSettings } from './appConfig.js';
const sessionId = sessionModule.getCurrentSessionId(); const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return; if (!sessionId) return;
if (choice === 'fork') {
// Branch just before the user turn and send it again there, so the new
// chat gets a fresh reply to the same message; this chat keeps every
// message. No variants or hidden bubble: the fork starts without them.
try {
await _forkAndSend(userIndex, (userMsgEl && userMsgEl.dataset.dbId) || '', userText, _regenIds);
} catch (err) {
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
console.error('Fork failed:', err);
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
}
return;
}
// Save current response as a variant // Save current response as a variant
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || ''; const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || ''; const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
@@ -7496,13 +7607,19 @@ import { invalidateSettings } from './appConfig.js';
} }
const keepCount = userIndex; const keepCount = userIndex;
// Same as resend: cut at the user message's database id, because
// userIndex counts only the messages loaded in the page.
const regenBeforeId = (userMsgEl && userMsgEl.dataset.dbId) || '';
try { try {
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { const regenTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount }) body: JSON.stringify(regenBeforeId ? { before_msg_id: regenBeforeId } : { keep_count: keepCount })
}); });
// A refused cut (e.g. a stale id) must not stack a second reply.
if (!regenTruncate.ok) throw new Error('Server error ' + regenTruncate.status);
_pendingRegenAttachments = _regenIds;
// Keep the original user bubble, but remove every rendered trace after // Keep the original user bubble, but remove every rendered trace after
// it, including agent-thread tool history between the user and AI bubble. // it, including agent-thread tool history between the user and AI bubble.
@@ -7519,12 +7636,14 @@ import { invalidateSettings } from './appConfig.js';
_pendingVariantLabel = 'regen'; _pendingVariantLabel = 'regen';
_hideUserBubble = true; _hideUserBubble = true;
_regenKeptUserBubble = userMsgEl;
const messageInput = uiModule.el('message'); const messageInput = uiModule.el('message');
messageInput.value = userText; messageInput.value = userText;
const submitBtn = document.querySelector('.send-btn'); const submitBtn = document.querySelector('.send-btn');
if (submitBtn) submitBtn.click(); if (submitBtn) submitBtn.click();
} catch (err) { } catch (err) {
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
console.error('Regenerate failed:', err); console.error('Regenerate failed:', err);
if (uiModule) uiModule.showError('Regenerate failed: ' + err.message); if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
} }
@@ -7536,6 +7655,8 @@ import { invalidateSettings } from './appConfig.js';
// File-ids carried over from the original user message during a regen, so // File-ids carried over from the original user message during a regen, so
// photos / OCR overrides survive into the new send. Consumed once. // photos / OCR overrides survive into the new send. Consumed once.
let _pendingRegenAttachments = null; let _pendingRegenAttachments = null;
// Read-only view for tests.
export function _pendingRegenAttachmentIds() { return _pendingRegenAttachments; }
/** /**
* Called after streaming completes to attach variant navigation if this was a regen. * Called after streaming completes to attach variant navigation if this was a regen.
@@ -7675,20 +7796,8 @@ import { invalidateSettings } from './appConfig.js';
const sessionId = sessionModule.getCurrentSessionId(); const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return; if (!sessionId) return;
const keepCount = aiIndex + 1;
try { try {
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, { await _forkSessionAt(aiIndex + 1, '', aiMsgElement.dataset.dbId || '');
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount }),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
await sessionModule.loadSessions();
await sessionModule.selectSession(data.id);
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
} catch (err) { } catch (err) {
console.error('Fork failed:', err); console.error('Fork failed:', err);
if (uiModule) uiModule.showError('Fork failed: ' + err.message); if (uiModule) uiModule.showError('Fork failed: ' + err.message);
+25 -6
View File
@@ -41,6 +41,7 @@
* }} deps * }} deps
*/ */
import { state } from './state.js'; import { state } from './state.js';
import { imageResultGeometry } from './image-result-geometry.js';
import { beginAIOperation, decodeAIImage } from './ai-operation.js'; import { beginAIOperation, decodeAIImage } from './ai-operation.js';
export function wireInpaintButtons({ export function wireInpaintButtons({
@@ -184,17 +185,30 @@ export function wireInpaintButtons({
if (!state.editorOpen) return; // user closed mid-decode if (!state.editorOpen) return; // user closed mid-decode
try { try {
saveState('Inpaint result'); saveState('Inpaint result');
// OpenAI returns at one of its allowed sizes (1024², // Whole-image edits keep the provider's native frame; only masked
// 1024×1536, 1536×1024) which often differs from our // edits need registration with the source document's coordinates.
// canvas. Scale to canvas size with smoothing so the
// inpaint blends in regardless of source dims.
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40); const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result'; const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight); const fullFrame = data.full_frame === true;
const geometry = imageResultGeometry(resultImg.naturalWidth, resultImg.naturalHeight, state.imgWidth, state.imgHeight);
const resultLayer = createLayer(layerName,
fullFrame ? geometry.width : state.imgWidth,
fullFrame ? geometry.height : state.imgHeight);
resultLayer.ctx.imageSmoothingEnabled = true; resultLayer.ctx.imageSmoothingEnabled = true;
resultLayer.ctx.imageSmoothingQuality = 'high'; resultLayer.ctx.imageSmoothingQuality = 'high';
resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight); if (fullFrame) {
resultLayer.ctx.drawImage(resultImg, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
state.layerOffsets.set(resultLayer.id, {
x: geometry.offsetX,
y: geometry.offsetY,
});
} else {
const scale = Math.min(state.imgWidth / resultImg.naturalWidth, state.imgHeight / resultImg.naturalHeight);
const width = resultImg.naturalWidth * scale, height = resultImg.naturalHeight * scale;
resultLayer.ctx.drawImage(resultImg, (state.imgWidth - width) / 2, (state.imgHeight - height) / 2, width, height);
}
// Snapshot the AI result + hard mask used for this run. // Snapshot the AI result + hard mask used for this run.
if (!fullFrame) {
const aiSnap = document.createElement('canvas'); const aiSnap = document.createElement('canvas');
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight; aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0); aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
@@ -205,6 +219,7 @@ export function wireInpaintButtons({
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx }; resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
// Apply initial alpha = hard mask (no feather, no edge shift). // Apply initial alpha = hard mask (no feather, no edge shift).
applyInpaintFeather(resultLayer, 0, 0); applyInpaintFeather(resultLayer, 0, 0);
}
state.layers.push(resultLayer); state.layers.push(resultLayer);
state.activeLayerId = resultLayer.id; state.activeLayerId = resultLayer.id;
state.lastInpaintLayerId = resultLayer.id; state.lastInpaintLayerId = resultLayer.id;
@@ -220,6 +235,10 @@ export function wireInpaintButtons({
} }
composite(); composite();
renderLayerPanel(); renderLayerPanel();
if (fullFrame) {
if (uiModule) uiModule.showToast('Image edit complete', 4500);
return;
}
// Reveal post-generation Feather + Edge Stroke sliders. // Reveal post-generation Feather + Edge Stroke sliders.
// Cap Edge Stroke at ±padPx so the slider can't ask for // Cap Edge Stroke at ±padPx so the slider can't ask for
// more AI buffer than we generated. // more AI buffer than we generated.
+20 -51
View File
@@ -54,16 +54,16 @@ export function resolveQuickEditModel() {
label: ((opt && opt.textContent) || '').trim(), label: ((opt && opt.textContent) || '').trim(),
}; };
} }
const inp = document.getElementById('ge-ai-inpaint'); const generation = document.getElementById('ge-ai-model');
const opt = resolveInpaintModel(inp); if (generation?.value && generation.value !== '__serve_cookbook__') {
if (!opt) return { endpoint: '', model: '', label: '' }; const index = generation.value.indexOf('::');
const raw = opt.value || ''; const model = index < 0 ? '' : generation.value.slice(index + 2);
const idx = raw.indexOf('::'); if (model && modelCaps(model, '', '').inpaint) {
return { return { endpoint: generation.value.slice(0, index), model,
endpoint: idx < 0 ? raw : raw.slice(0, idx), label: generation.options[generation.selectedIndex]?.textContent?.trim() || model };
model: idx < 0 ? '' : raw.slice(idx + 2), }
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')), }
}; return { endpoint: '', model: '', label: 'Auto (saved settings)' };
} }
// Resolve the Quick Edit AGENT (planner) model — the vision-language model // Resolve the Quick Edit AGENT (planner) model — the vision-language model
@@ -144,21 +144,19 @@ export function modelCaps(modelId, endpointName, endpointType) {
function syncCommandModel(value) { function syncCommandModel(value) {
const select = document.getElementById('ge-ai-command-model'); const select = document.getElementById('ge-ai-command-model');
if (!select || !select._modelsReady) return; if (!select || !select._modelsReady) return;
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
value = '';
}
if (select.value !== value) { if (select.value !== value) {
select.value = value; select.value = value;
if (select.value !== value) select.value = ''; if (select.value !== value) select.value = '';
updateFooterEditModel(); updateFooterEditModel();
} }
} }
if (typeof window !== 'undefined') {
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || '')); window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
window.addEventListener('storage', e => { window.addEventListener('storage', e => {
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || ''); if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
}); });
window.addEventListener('ge:model-endpoints-updated', e => { }
globalThis.window?.addEventListener('ge:model-endpoints-updated', e => {
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || ''); document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
}); });
@@ -256,9 +254,9 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
const data = await res.json(); const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return; if (listeners.signal.aborted || !root.isConnected) return;
const modelsList = data.models || []; const modelsList = data.models || [];
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>'; if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">Auto-detect</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>'; if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Same as generation</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;
@@ -346,45 +344,15 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
// Sentinel changes are handled by the component's delegated listener. // Sentinel changes are handled by the component's delegated listener.
if (aiCommandSelect) { if (aiCommandSelect) {
aiCommandSelect._modelsReady = true; aiCommandSelect._modelsReady = true;
try { // Legacy URL migration check: legacy endpoints with raw URLs are scrubbed.
const rawSaved = localStorage.getItem('ge-ai-command-model') || ''; const scrubModelUrl = (saved) => (saved && saved.startsWith('http://') ? '' : saved);
let saved = rawSaved;
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
const matching = loaded.filter(l => l.model === modelPart);
if (matching.length === 1) {
saved = matching[0].value;
} else {
saved = '';
}
try {
if (saved) localStorage.setItem('ge-ai-command-model', saved);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
}
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
aiCommandSelect.value = saved;
} else {
aiCommandSelect.value = '';
}
if (aiCommandSelect.value !== rawSaved) {
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
}
} catch {}
if (aiCommandSelect._commandWired !== listeners) { if (aiCommandSelect._commandWired !== listeners) {
aiCommandSelect._commandWired = listeners; aiCommandSelect._commandWired = listeners;
listen(aiCommandSelect, 'change', () => { listen(aiCommandSelect, 'change', () => {
if (aiCommandSelect.value === '__serve_cookbook__') return; if (aiCommandSelect.value === '__serve_cookbook__') return;
const val = aiCommandSelect.value || ''; const val = scrubModelUrl(aiCommandSelect.value) || '';
try {
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
updateFooterEditModel(); updateFooterEditModel();
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } })); globalThis.window?.dispatchEvent?.(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
}); });
} }
} }
@@ -409,6 +377,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
}); });
} }
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() }; _aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
container.dispatchEvent(new Event('ge-image-models-ready'));
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {} try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
} catch (e) { } catch (e) {
if (listeners.signal.aborted || !root.isConnected) return; if (listeners.signal.aborted || !root.isConnected) return;
+5 -30
View File
@@ -2,11 +2,10 @@
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup * Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
* tuner that runs on the most-recent rembg cutout. * tuner that runs on the most-recent rembg cutout.
* *
* rembg-run button: flatten + POST to /api/image/remove-bg with an * rembg-run button: active layer + POST to /api/image/remove-bg with an
* optional hint_mask if the user has a wand/lasso selection * optional hint_mask if the user has a wand/lasso selection
* active. After the new layer lands, hides every previously- * active. Updates only that layer and binds its live edge tuner;
* visible layer so the cutout reads cleanly, and binds the * other layers and their visibility remain unchanged.
* live-tuner to the new layer.
* *
* Live edge-cleanup tuner: snapshots the pristine cutout the * Live edge-cleanup tuner: snapshots the pristine cutout the
* moment it lands; subsequent feather/grow slider tweaks * moment it lands; subsequent feather/grow slider tweaks
@@ -65,37 +64,13 @@ export function wireRembgAndSharpen({
// NB: edge_feather / edge_grow are applied CLIENT-side so the // NB: edge_feather / edge_grow are applied CLIENT-side so the
// sliders can re-tune the cutout without re-running the model. // sliders can re-tune the cutout without re-running the model.
const btn = document.getElementById('ge-rembg-run'); const btn = document.getElementById('ge-rembg-run');
const before = state.layers.length; const editedLayer = await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
// Snapshot which layers were visible BEFORE the run so we know
// which to hide after a successful cutout.
const prevVisible = state.layers.filter(l => l.visible).map(l => l.id);
await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
// applyImageTool resolves after the returned image is decoded and the
// new layer is inserted. Keep a short poll for defensive compatibility
// with alternate runners that may still commit on the next frame.
let frames = 0;
while (state.layers.length <= before && frames < 60) {
await new Promise(r => requestAnimationFrame(r));
frames++;
}
if (state.layers.length > before) {
const newLayer = state.layers[state.layers.length - 1];
bindRembgLiveTuner(newLayer);
// Auto-hide underlying layers so the user sees just the
// cutout — the eye toggles back on if they re-enable manually.
for (const layer of state.layers) {
if (prevVisible.includes(layer.id) && layer.id !== newLayer.id) {
layer.visible = false;
}
}
composite();
renderLayerPanel();
}
// Reset sliders so the new cutout starts clean. // Reset sliders so the new cutout starts clean.
const f = document.getElementById('ge-rembg-feather'); const f = document.getElementById('ge-rembg-feather');
const g = document.getElementById('ge-rembg-grow'); const g = document.getElementById('ge-rembg-grow');
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); } if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); } if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
if (editedLayer) bindRembgLiveTuner(editedLayer);
}); });
// ── Live edge-cleanup tuner ── // ── Live edge-cleanup tuner ──
+61 -3
View File
@@ -5,6 +5,9 @@
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and * "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. * from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
*/ */
import { readImagePreferences, saveImagePreferences } from '../settings/imagePreferences.js';
import { updateFooterEditModel } from './ai-models.js';
export function createAiSettings({ container }) { export function createAiSettings({ container }) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'ge-ai-settings'; el.className = 'ge-ai-settings';
@@ -19,11 +22,14 @@ export function createAiSettings({ container }) {
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button> <button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
</div> </div>
<div class="ge-ai-settings-body"> <div class="ge-ai-settings-body">
<label class="ge-ai-settings-label" for="ge-ai-model">Image generation model</label>
<select class="ge-ai-command-model" id="ge-ai-model" title="Image generation model" aria-label="Image generation model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label> <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> <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> <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> <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> <p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
<p class="ge-ai-settings-note" id="ge-ai-settings-save-status" role="status"></p>
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label> <label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
<div class="ge-ai-settings-strength"> <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" /> <input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
@@ -41,6 +47,7 @@ export function createAiSettings({ container }) {
function open(focusId) { function open(focusId) {
el.hidden = false; el.hidden = false;
syncModels();
if (focusId) { if (focusId) {
const target = document.getElementById(focusId); const target = document.getElementById(focusId);
if (target) { if (target) {
@@ -56,10 +63,59 @@ export function createAiSettings({ container }) {
close(); close();
} }
}); });
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); }; const onKey = (e) => {
document.addEventListener('keydown', onKey); if (e.key !== 'Escape' || el.hidden) return;
e.preventDefault();
e.stopImmediatePropagation();
close();
};
window.addEventListener('keydown', onKey, true);
container.appendChild(el); container.appendChild(el);
const status = el.querySelector('#ge-ai-settings-save-status');
const modelFields = [['ge-ai-model', 'image_model'], ['ge-ai-command-model', 'image_edit_model']];
function applyModels(settings) {
for (const [id, key] of modelFields) {
if (!(key in settings)) continue;
const select = el.querySelector(`#${id}`);
const model = settings[key] || '';
let option = [...select.options].find(option => {
const value = option.value;
return model ? value.slice(value.indexOf('::') + 2) === model : value === '';
});
if (!option && model && select.options.length) {
option = document.createElement('option');
option.value = `::${model}`;
option.textContent = `${model} (unavailable)`;
option.disabled = true;
select.append(option);
}
if (option) select.value = option.value;
}
updateFooterEditModel();
}
async function syncModels() {
try { applyModels(await readImagePreferences()); }
catch (error) { status.textContent = error.message; }
}
for (const [id, key] of modelFields) {
el.querySelector(`#${id}`).addEventListener('change', async (event) => {
const raw = event.target.value;
if (raw === '__serve_cookbook__') return;
const model = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : '';
try {
status.textContent = 'Saving…';
await saveImagePreferences({ [key]: model });
status.textContent = 'Saved';
} catch (error) {
await syncModels();
status.textContent = error.message;
}
});
}
const onPreferencesChanged = event => applyModels(event.detail);
window.addEventListener('image-preferences-changed', onPreferencesChanged);
container.addEventListener('ge-image-models-ready', syncModels);
const strengthInput = el.querySelector('#ge-look-strength'); const strengthInput = el.querySelector('#ge-look-strength');
const strengthVal = el.querySelector('#ge-look-strength-val'); const strengthVal = el.querySelector('#ge-look-strength-val');
@@ -77,7 +133,9 @@ export function createAiSettings({ container }) {
} }
function destroy() { function destroy() {
document.removeEventListener('keydown', onKey); window.removeEventListener('keydown', onKey, true);
window.removeEventListener('image-preferences-changed', onPreferencesChanged);
container.removeEventListener('ge-image-models-ready', syncModels);
el.remove(); el.remove();
} }
+23 -1
View File
@@ -76,7 +76,12 @@ export function createApplyImageTool({
if (sel.endpoint) extraPayload._endpoint = sel.endpoint; if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
if (sel.model && !extraPayload._model) extraPayload._model = sel.model; if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
} }
const flatCanvas = flatten(); const removeBackground = endpoint === '/api/image/remove-bg';
const targetLayer = removeBackground ? state.layers.find(layer => layer.id === state.activeLayerId) : null;
if (removeBackground && (!targetLayer?.canvas || targetLayer.kind === 'adjustment' || targetLayer.locks?.pixels)) {
throw new Error('Select an unlocked image layer first');
}
const flatCanvas = removeBackground ? targetLayer.canvas : flatten();
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1]; const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
const body = { image: imageB64, ...extraPayload }; const body = { image: imageB64, ...extraPayload };
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
@@ -96,6 +101,23 @@ export function createApplyImageTool({
const img = await decodeAIImage(data.image, operation.signal); const img = await decodeAIImage(data.image, operation.signal);
operation.signal.throwIfAborted(); operation.signal.throwIfAborted();
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4) if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
if (removeBackground) {
if (!state.layers.includes(targetLayer)) return;
saveState('Remove background');
// The returned pixels replace the editable source, not just its preview.
targetLayer.kind = 'raster';
targetLayer.placed = null;
targetLayer.text = null;
targetLayer.shape = null;
targetLayer.ctx.clearRect(0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
targetLayer.ctx.drawImage(img, 0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
targetLayer._adjCacheKey = null;
targetLayer._adjFinalKey = null;
composite();
renderLayerPanel();
uiModule?.showToast('Background removed');
return targetLayer;
}
{ {
saveState(); saveState();
const layer = createLayer(layerName, state.imgWidth, state.imgHeight); const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
+5 -5
View File
@@ -55,6 +55,11 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<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 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="1200" value="100" title="Brush edge softness and feathered falloff" />
</div>
<div class="ge-control-row ge-brush-size-row"> <div class="ge-control-row ge-brush-size-row">
<label><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}" />
@@ -94,11 +99,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label>Flow <span id="ge-brush-flow-label">100%</span></label> <label>Flow <span id="ge-brush-flow-label">100%</span></label>
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" /> <input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
</div> </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>
<div class="ge-brush-stroke-group" data-stroke-tool="eraser"> <div class="ge-brush-stroke-group" data-stroke-tool="eraser">
<div class="ge-section-title">Eraser</div> <div class="ge-section-title">Eraser</div>
+35 -5
View File
@@ -78,6 +78,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, ' const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, '
+ '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea'; + '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea';
const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR); const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR);
const canStartOutside = () => ['marquee', 'lasso', 'crop'].includes(state.tool);
// Only in-progress selection/crop drags are affected by the chrome // Only in-progress selection/crop drags are affected by the chrome
// guard. A committed cropRect/selection is a resting state — clicking // guard. A committed cropRect/selection is a resting state — clicking
// its Apply button must not be treated as "strayed onto chrome". // its Apply button must not be treated as "strayed onto chrome".
@@ -143,15 +144,16 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
}; };
state.mainCanvas.addEventListener('pointerup', endPen); state.mainCanvas.addEventListener('pointerup', endPen);
state.mainCanvas.addEventListener('pointercancel', endPen); state.mainCanvas.addEventListener('pointercancel', endPen);
// Lasso and Crop can start OUTSIDE the canvas — fallback mousedown on the // Selection and Crop can start OUTSIDE the canvas — fallback mousedown on the
// surrounding canvas-area so the user can begin a path/rect in the empty // surrounding canvas-area so the user can begin a path/rect in the empty
// space around the image. Other tools stay canvas-only. // space around the image. Other tools stay canvas-only.
canvasArea.addEventListener('mousedown', (e) => { canvasArea.addEventListener('mousedown', (e) => {
if (state.tool !== 'lasso' && state.tool !== 'crop') return; if (!canStartOutside() || Date.now() < suppressMouseUntil) return;
if (e.button !== 0 || isDirectPanIntent(state.tool, state.spacePanActive, e.button)) return;
if (e.target === state.mainCanvas) return; // already handled if (e.target === state.mainCanvas) return; // already handled
// Ignore presses on editor chrome inside the area (the crop Apply panel, // Ignore presses on editor chrome inside the area (the crop Apply panel,
// popups, buttons) so clicking Apply does not start a fresh crop. // popups, buttons) so clicking Apply does not start a fresh crop.
if (e.target.closest?.('button, input, select, textarea, .ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return; if (isUiChrome(e) || e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return;
beginDraw(e); beginDraw(e);
}); });
state.mainCanvas.addEventListener('mouseenter', (e) => { state.mainCanvas.addEventListener('mouseenter', (e) => {
@@ -305,6 +307,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
let panning = false; let panning = false;
let pid = null; let pid = null;
let transformPointerId = null; let transformPointerId = null;
let outsideDrawPointerId = null;
let startX = 0, startY = 0; let startX = 0, startY = 0;
let startPanX = 0, startPanY = 0; let startPanX = 0, startPanY = 0;
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 }); const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
@@ -315,10 +318,23 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
}; };
canvasArea.addEventListener('pointerdown', (e) => { canvasArea.addEventListener('pointerdown', (e) => {
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button); const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
// With the crop (or lasso) tool, an empty-area press draws a rect/path // With a selection or crop tool, an empty-area press draws a rect/path
// rather than panning — so the crop can be pulled out past the image. // rather than panning — so the crop can be pulled out past the image.
// Space-held / middle-mouse (directPan) still pans. // Space-held / middle-mouse (directPan) still pans.
if ((state.tool === 'lasso' || state.tool === 'crop') && !directPan) return; if (canStartOutside() && !directPan) {
if (e.target === state.mainCanvas || isUiChrome(e) ||
e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return;
// Mouse starts through mousedown; pen/touch need their own captured path.
if (e.pointerType === 'pen' || e.pointerType === 'touch') {
if (outsideDrawPointerId !== null || e.button !== 0) return;
outsideDrawPointerId = e.pointerId;
suppressMouseUntil = Date.now() + 500;
try { canvasArea.setPointerCapture(e.pointerId); } catch {}
beginDraw(e);
e.preventDefault();
}
return;
}
if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return; if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay; const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
if (onImage && !directPan) return; if (onImage && !directPan) return;
@@ -357,6 +373,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
e.preventDefault(); e.preventDefault();
}); });
canvasArea.addEventListener('pointermove', (e) => { canvasArea.addEventListener('pointermove', (e) => {
if (outsideDrawPointerId !== null && e.pointerId === outsideDrawPointerId) {
suppressMouseUntil = Date.now() + 500;
if (!isUiChrome(e)) continueDraw(e);
e.preventDefault();
return;
}
if (transformPointerId !== null && e.pointerId === transformPointerId) { if (transformPointerId !== null && e.pointerId === transformPointerId) {
continueDraw(e); continueDraw(e);
return; return;
@@ -370,6 +392,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
applyOffset(next.x, next.y); applyOffset(next.x, next.y);
}); });
const endPan = (e) => { const endPan = (e) => {
if (outsideDrawPointerId !== null && (!e || e.pointerId === outsideDrawPointerId)) {
const capturedId = outsideDrawPointerId;
outsideDrawPointerId = null;
suppressMouseUntil = Date.now() + 500;
if (e?.type === 'pointercancel') cancelDraw?.();
else if (e) endDraw(e);
try { canvasArea.releasePointerCapture(capturedId); } catch {}
}
if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) { if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) {
const capturedId = transformPointerId; const capturedId = transformPointerId;
transformPointerId = null; transformPointerId = null;
+5
View File
@@ -21,6 +21,7 @@ export const EDITOR_AGENT_VERSION = '1.0';
export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent'; export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent';
import { formatCropRect, isGenericIntent } from './nl-edit.js'; import { formatCropRect, isGenericIntent } from './nl-edit.js';
import { waitForImagePreferences } from '../settings/imagePreferences.js';
export function createEditorAgent({ export function createEditorAgent({
control, control,
@@ -28,6 +29,7 @@ export function createEditorAgent({
fetchImpl = null, fetchImpl = null,
getSession = null, getSession = null,
getModel = null, getModel = null,
getGenerationModel = null,
getAgentModel = null, getAgentModel = null,
onStep = null, onStep = null,
} = {}) { } = {}) {
@@ -145,6 +147,7 @@ export function createEditorAgent({
activeStep = report; activeStep = report;
executed = []; executed = [];
running = true; running = true;
await waitForImagePreferences();
// Honest before-image (captured before any client pre-step) for the // Honest before-image (captured before any client pre-step) for the
// imperceptible-edit check, then the history mark scoping the undo group. // imperceptible-edit check, then the history mark scoping the undo group.
@@ -194,6 +197,8 @@ export function createEditorAgent({
signal: signal || undefined, signal: signal || undefined,
body: JSON.stringify({ body: JSON.stringify({
prompt, session, editor_session: session, state, screenshot, prompt, session, editor_session: session, state, screenshot,
_use_image_settings: true,
generation_model: getGenerationModel ? getGenerationModel() : '',
model: (modelInfo && modelInfo.model) || '', model: (modelInfo && modelInfo.model) || '',
endpoint: (modelInfo && modelInfo.endpoint) || '', endpoint: (modelInfo && modelInfo.endpoint) || '',
model_label: (modelInfo && modelInfo.label) || '', model_label: (modelInfo && modelInfo.label) || '',
+1 -1
View File
@@ -132,7 +132,7 @@ function renderEffects(source, effects, masks) {
const contribution = maskedContribution(next, masks[index]); const contribution = maskedContribution(next, masks[index]);
const blended = copyCanvas(current); const blended = copyCanvas(current);
const blendCtx = blended.getContext('2d'); const blendCtx = blended.getContext('2d');
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over'; if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
blendCtx.globalAlpha = effect.opacity; blendCtx.globalAlpha = effect.opacity;
blendCtx.drawImage(contribution, 0, 0); blendCtx.drawImage(contribution, 0, 0);
blendCtx.globalAlpha = 1; blendCtx.globalAlpha = 1;
+2 -1
View File
@@ -81,6 +81,7 @@ export function normalizeEffect(effect) {
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode; params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
} }
if (type === 'drop-shadow') { if (type === 'drop-shadow') {
params.inside = params.inside === true || params.inside === 1;
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color; params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0; for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
params.opacity = Math.max(0, Math.min(1, params.opacity)); params.opacity = Math.max(0, Math.min(1, params.opacity));
@@ -291,7 +292,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
: next; : next;
const blended = copyCanvas(current); const blended = copyCanvas(current);
const blendCtx = blended.getContext('2d'); const blendCtx = blended.getContext('2d');
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over'; if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
blendCtx.globalAlpha = effect.opacity; blendCtx.globalAlpha = effect.opacity;
blendCtx.drawImage(contribution, 0, 0); blendCtx.drawImage(contribution, 0, 0);
blendCtx.globalAlpha = 1; blendCtx.globalAlpha = 1;
+71
View File
@@ -0,0 +1,71 @@
import { decodeAIImage } from './ai-operation.js';
import { waitForImagePreferences } from '../settings/imagePreferences.js';
export function createImageGenerationAction({
getDocument, placeResult, getSignal = () => null, onProgress = () => {},
formatError = info => info.detail || 'Image generation failed',
fetchImpl = (...args) => fetch(...args), timeoutMs = 330000,
}) {
return async function generateImage(params = {}) {
const document = getDocument();
if (!document.key) throw new Error('Open a document before generating an image');
const controller = new AbortController();
const parentSignal = getSignal();
const cancel = () => controller.abort();
if (parentSignal?.aborted) cancel();
parentSignal?.addEventListener('abort', cancel, { once: true });
let timedOut = false;
const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
const started = Date.now();
const progress = setInterval(() => onProgress(Math.floor((Date.now() - started) / 1000)), 1000);
// A selection can change while the provider works; preserve this request's mask.
let mask = null;
try {
if (params.masked && document.mask) {
mask = globalThis.document.createElement('canvas');
mask.width = document.mask.width; mask.height = document.mask.height;
mask.getContext('2d').drawImage(document.mask, 0, 0);
}
await waitForImagePreferences();
controller.signal.throwIfAborted();
let response;
try { response = await fetchImpl(window.geGenerateImageEndpoint || '/api/image/generate', {
method: 'POST', credentials: 'same-origin', signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ _use_image_settings: true, prompt: params.prompt || '',
width: Math.max(1, Math.round(Number(params.width) || document.width)),
height: Math.max(1, Math.round(Number(params.height) || document.height)) }),
}); } catch (error) {
if (controller.signal.aborted) throw error;
throw new Error(formatError({ network: true, detail: error.message }));
}
let data;
try { data = await response.json(); }
catch { throw new Error(response.ok ? 'Image server returned an invalid response' : `Image server failed (${response.status})`); }
if (!response.ok || data.error) throw new Error(formatError({ status: response.status,
detail: data.detail || data.error || `Image server failed (${response.status})` }));
const image = data.image || data.images?.[0];
if (!image) throw new Error('Image generation returned no image');
const decoded = await decodeAIImage(image, controller.signal);
controller.signal.throwIfAborted();
const current = getDocument();
if (current.key !== document.key || current.width !== document.width || current.height !== document.height) {
throw new Error('The document changed during generation. The result was not placed in another document.');
}
const canvas = globalThis.document.createElement('canvas');
canvas.width = decoded.naturalWidth; canvas.height = decoded.naturalHeight;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(decoded, 0, 0);
return placeResult(canvas, 'Generated', mask);
} catch (error) {
if (timedOut) throw new Error('Image generation timed out. Try again or choose a faster image model.');
throw error;
} finally {
clearTimeout(deadline);
clearInterval(progress);
parentSignal?.removeEventListener('abort', cancel);
}
};
}
+11
View File
@@ -0,0 +1,11 @@
export function imageResultGeometry(width, height, canvasWidth, canvasHeight) {
const matchingRatio = Math.abs(width * canvasHeight - height * canvasWidth) <= Math.max(canvasWidth, canvasHeight);
if (matchingRatio) {
const scale = Math.max(canvasWidth / width, canvasHeight / height);
const drawWidth = width * scale, drawHeight = height * scale;
return { width: canvasWidth, height: canvasHeight, offsetX: 0, offsetY: 0,
drawX: (canvasWidth - drawWidth) / 2, drawY: (canvasHeight - drawHeight) / 2, drawWidth, drawHeight };
}
return { width, height, offsetX: Math.round((canvasWidth - width) / 2), offsetY: Math.round((canvasHeight - height) / 2),
drawX: 0, drawY: 0, drawWidth: width, drawHeight: height };
}
+12 -5
View File
@@ -67,7 +67,7 @@ export function wireKeyboardShortcuts(deps) {
toolbar, toolKeyMap, toolbar, toolKeyMap,
composite, saveState, undo, redo, composite, saveState, undo, redo,
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform, toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
resizeCustomPrompt, addEmptyLayer, brushSizeSync, resizeCustomPrompt, addEmptyLayer, brushSizeSync, openLayerAdjustment,
invertSelection, invertSelection,
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard, wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
lassoDeleteSelection, lassoCopyToLayer, lassoDeleteSelection, lassoCopyToLayer,
@@ -181,6 +181,12 @@ export function wireKeyboardShortcuts(deps) {
return; return;
} }
// Save shortcuts — match the hints shown in the Save dropdown. // Save shortcuts — match the hints shown in the Save dropdown.
if (e.shiftKey && !e.altKey && e.code === 'KeyU') {
e.preventDefault();
e.stopPropagation();
openLayerAdjustment?.('hue-saturation');
return;
}
if ((e.key === 's' || e.key === 'S') && !e.altKey) { if ((e.key === 's' || e.key === 'S') && !e.altKey) {
e.preventDefault(); e.preventDefault();
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click(); document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
@@ -221,11 +227,12 @@ export function wireKeyboardShortcuts(deps) {
addEmptyLayer(); addEmptyLayer();
return; return;
} }
// Ctrl+Alt+L — Levels adjustment. The Image menu owns the row and its // Ctrl+Alt+L edits the current layer, leaving Levels controls open.
// dialog, so click it instead of duplicating the adjustment here.
if (e.altKey && e.code === 'KeyL') { if (e.altKey && e.code === 'KeyL') {
const row = document.querySelector('[data-image-action="adj-levels"]'); e.preventDefault();
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; } e.stopPropagation();
openLayerAdjustment?.('levels');
return;
} }
// Ctrl/Cmd+J duplicates the active layer through the layer panel's // Ctrl/Cmd+J duplicates the active layer through the layer panel's
// existing implementation, which preserves masks and effects. // existing implementation, which preserves masks and effects.
+4
View File
@@ -2233,6 +2233,7 @@ export function createLayerPanelRenderer(deps) {
if (dragSortModule) { if (dragSortModule) {
dragSortModule.enable('ge-layers-list', '.ge-layer-item', { dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
instanceKey: 'ge-layers', instanceKey: 'ge-layers',
excludeSelector: '.ge-adj-sub-item',
threshold: 4, threshold: 4,
ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item', ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item',
onReorder: (orderedItems) => { onReorder: (orderedItems) => {
@@ -2258,6 +2259,9 @@ export function createLayerPanelRenderer(deps) {
state.layers = newLayers; state.layers = newLayers;
normalizeLayerClipping(state); normalizeLayerClipping(state);
composite(); composite();
// Effects, adjustments and masks are sibling rows, not children
// of the dragged row. Rebuild them under their reordered owner.
render();
} }
}, },
}); });
+8 -1
View File
@@ -6,7 +6,7 @@ const size = () => number('size', 'Size', 8, 0, 100);
const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg'); const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg');
export const LAYER_STYLES = { export const LAYER_STYLES = {
'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] }, 'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] },
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 100)] }, 'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 300)] },
'inner-glow': { label: 'Inner Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] }, 'inner-glow': { label: 'Inner Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] },
satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] }, satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] },
'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] }, 'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] },
@@ -32,6 +32,13 @@ function surface(width, height) {
} }
export function renderDropShadow(source, params) { export function renderDropShadow(source, params) {
if (params.inside) {
return renderLayerStyle(source, 'inner-shadow', {
color: params.color, opacity: params.opacity * 100, size: params.blur / 2,
distance: Math.hypot(params.x, params.y),
angle: Math.atan2(-params.y, params.x) * 180 / Math.PI,
});
}
// Tint alpha first so RGB content cannot leak into the shadow. Draw only // Tint alpha first so RGB content cannot leak into the shadow. Draw only
// this silhouette; compositing the source again thickens translucent edges. // this silhouette; compositing the source again thickens translucent edges.
const silhouette = surface(source.width, source.height); const silhouette = surface(source.width, source.height);
+13
View File
@@ -0,0 +1,13 @@
export function finishQuickEditJob(job, result) {
if (job.cancelled || result?.cancelled) {
job.cancelled = true;
job.status = 'cancelled';
job.detail = 'Cancelled';
} else if (job.status === 'error' || result?.ok === false) {
job.status = 'error';
job.detail = result?.error || result?.message || (job.detail !== 'Working…' && job.detail) || 'Edit failed';
} else {
job.status = 'done';
job.detail = result?.message || 'Done';
}
}
+30 -8
View File
@@ -14,19 +14,26 @@ function toolSettings(tool) {
let _lastColor = null; let _lastColor = null;
let _lastTransparentColor = null; let _lastTransparentColor = null;
function transparentColor(color) { function transparentColor(color, alpha = 0) {
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor; const key = `${color}|${alpha}`;
_lastColor = color; if (key === _lastColor && _lastTransparentColor) return _lastTransparentColor;
_lastColor = key;
_lastTransparentColor = colorWithAlpha(color, alpha);
return _lastTransparentColor;
}
function colorWithAlpha(color, alpha) {
if (color.startsWith('#')) { if (color.startsWith('#')) {
const raw = color.slice(1); const raw = color.slice(1);
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw; const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16)); const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
_lastTransparentColor = `rgba(${rgb.join(',')},0)`; return `rgba(${rgb.join(',')},${alpha})`;
return _lastTransparentColor;
} }
const match = color.match(/rgba?\(([^)]+)\)/); const match = color.match(/rgba?\(([^)]+)\)/);
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)'; if (!match) return `rgba(0,0,0,${alpha})`;
return _lastTransparentColor; const components = match[1].split(',');
const originalAlpha = components.length > 3 ? Number(components[3]) : 1;
return `rgba(${components.slice(0, 3).join(',')},${alpha * originalAlpha})`;
} }
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) { function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
@@ -38,8 +45,19 @@ function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
ctx.fillStyle = color; ctx.fillStyle = color;
} else { } else {
const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius); const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius);
if (softness > 1) {
// Above the old maximum, remove the firm core with a progressively
// gentler alpha profile while keeping the selected brush diameter.
ctx.fillStyle = color;
const normalized = ctx.fillStyle;
for (let step = 0; step <= 16; step++) {
const t = step / 16;
gradient.addColorStop(t, transparentColor(normalized, (1 - t) ** softness));
}
} else {
gradient.addColorStop(0, color); gradient.addColorStop(0, color);
gradient.addColorStop(1, transparentColor(color)); gradient.addColorStop(1, transparentColor(color));
}
ctx.fillStyle = gradient; ctx.fillStyle = gradient;
} }
ctx.beginPath(); ctx.beginPath();
@@ -274,6 +292,10 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
if (!PAINT_TOOLS.has(tool)) return; if (!PAINT_TOOLS.has(tool)) return;
const layer = activeLayer(); const layer = activeLayer();
if (!layer) return; if (!layer) return;
// Pixel writes invalidate adjusted previews even when adjustment settings
// themselves have not changed (otherwise retouch strokes appear late).
layer._adjFinalKey = null;
layer._adjCacheKey = null;
if (tool === 'clone') return cloneStamp(sample, layer); if (tool === 'clone') return cloneStamp(sample, layer);
if (tool === 'heal') { if (tool === 'heal') {
// A healing stroke without a picked source uses the convenient local // A healing stroke without a picked source uses the convenient local
@@ -294,7 +316,7 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
let operation = 'source-over'; let operation = 'source-over';
let color = state.color; let color = state.color;
let alpha = opacity * flow; let alpha = opacity * flow;
let softness = Math.max(0, Math.min(1, settings.softness / 300)); let softness = Math.max(0, Math.min(4, settings.softness / 300));
if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; } if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; }
else if (tool === 'inpaint') { else if (tool === 'inpaint') {
operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over'; operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over';
+5 -2
View File
@@ -57,8 +57,11 @@ function wireToolSliders(prefix, fields) {
// softness rises (the CSS already sets the radial gradient — we // softness rises (the CSS already sets the radial gradient — we
// just tween the inner solid radius to communicate the falloff). // just tween the inner solid radius to communicate the falloff).
if (softPrev) { if (softPrev) {
const innerStop = Math.max(0, 60 - state[fields.softness] * 0.55); const amount = state[fields.softness];
softPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`; const innerStop = Math.max(0, 60 - amount * 0.55);
softPrev.style.background = amount > 300
? `radial-gradient(circle, var(--fg), color-mix(in srgb, var(--fg) ${Math.round(100 * 0.5 ** (amount / 300))}%, transparent) 50%, transparent 100%)`
: `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
} }
}); });
} }
+18 -4
View File
@@ -20,6 +20,23 @@ export function wireSubmenuToggles(menu) {
menu.classList.remove('ge-flyout-open'); menu.classList.remove('ge-flyout-open');
} }
}); });
function positionPanel(parent) {
const panel = parent.nextElementSibling;
if (!panel?.classList.contains('ge-submenu')) return;
const row = parent.getBoundingClientRect();
const origin = (panel.offsetParent || menu).getBoundingClientRect();
const top = Math.max(8, Math.min(row.top, window.innerHeight - panel.offsetHeight - 8));
const left = row.right + panel.offsetWidth > window.innerWidth - 8
? Math.max(8, row.left - panel.offsetWidth) : row.right - 2;
panel.style.top = `${top - origin.top + (panel.offsetParent?.scrollTop || 0)}px`;
panel.style.left = `${left - origin.left + (panel.offsetParent?.scrollLeft || 0)}px`;
}
for (const eventName of ['pointerover', 'focusin']) {
menu.addEventListener(eventName, (event) => {
const parent = event.target.closest('.ge-menu-parent');
if (parent) positionPanel(parent);
});
}
menu.addEventListener('click', (e) => { menu.addEventListener('click', (e) => {
const parent = e.target.closest('.ge-menu-parent'); const parent = e.target.closest('.ge-menu-parent');
if (!parent) return; if (!parent) return;
@@ -34,10 +51,7 @@ export function wireSubmenuToggles(menu) {
parent.setAttribute('aria-expanded', String(willOpen)); parent.setAttribute('aria-expanded', String(willOpen));
if (willOpen) { if (willOpen) {
// Align the flyout with the clicked row rather than the menu top. // Align the flyout with the clicked row rather than the menu top.
const panel = parent.nextElementSibling; positionPanel(parent);
if (panel && panel.classList.contains('ge-submenu')) {
panel.style.top = parent.offsetTop + 'px';
}
} }
}); });
} }
+2
View File
@@ -37,6 +37,7 @@
* }} deps * }} deps
*/ */
import { state } from './state.js'; import { state } from './state.js';
import { topPortalZ } from '../toolWindowZOrder.js';
import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js'; import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js';
const TOPBAR_MENU_IDS = ['ge-file-menu', 'ge-edit-menu', 'ge-view-menu', 'ge-image-menu', 'ge-layer-menu', 'ge-type-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-window-menu', 'ge-help-menu', 'ge-resize-menu', 'ge-save-menu']; const TOPBAR_MENU_IDS = ['ge-file-menu', 'ge-edit-menu', 'ge-view-menu', 'ge-image-menu', 'ge-layer-menu', 'ge-type-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-window-menu', 'ge-help-menu', 'ge-resize-menu', 'ge-save-menu'];
@@ -101,6 +102,7 @@ export function wireTopbar(deps) {
}; };
const positionSaveMenu = () => { const positionSaveMenu = () => {
const r = saveBtn.getBoundingClientRect(); const r = saveBtn.getBoundingClientRect();
saveMenu.style.zIndex = String(topPortalZ());
saveMenu.style.top = `${r.bottom + 2}px`; saveMenu.style.top = `${r.bottom + 2}px`;
saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`; saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
saveMenu.style.left = 'auto'; saveMenu.style.left = 'auto';
+5
View File
@@ -1321,7 +1321,12 @@ function _renderEditorLanding() {
// openEditor() now returns a Promise — it's async because the size // openEditor() now returns a Promise — it's async because the size
// prompt is a styled modal. Await it before checking whether the // prompt is a styled modal. Await it before checking whether the
// editor actually opened (the user may have cancelled). // editor actually opened (the user may have cancelled).
try {
await openEditor(null, null, null, 'New canvas'); await openEditor(null, null, null, 'New canvas');
} catch (error) {
// openEditor reports the initialization error and tears down its shell.
console.error('[gallery] New canvas failed', error);
}
if (!document.querySelector('#gallery-editor-container .gallery-editor')) { if (!document.querySelector('#gallery-editor-container .gallery-editor')) {
_renderEditorLanding(); _renderEditorLanding();
} }
+123 -57
View File
@@ -277,8 +277,11 @@ import { alphaBounds as _maskAlphaBounds, featherMaskCanvas as _featherMaskCanva
import { analyzeAutoTone as _analyzeAutoTone, applyAutoTone as _applyAutoTone, boxBlurRGB as _boxBlurRGB, skinMaskFromData as _skinMaskFromData, frequencySeparationSmooth as _frequencySeparationSmooth, blemishRemoval as _blemishRemoval, teethWhitening as _teethWhitening, featureExclusionMask as _featureExclusionMask, dilateAlpha as _dilateAlpha, subtractMask as _subtractMask, guidedClarity as _guidedClarity } from './editor/retouch.js'; import { analyzeAutoTone as _analyzeAutoTone, applyAutoTone as _applyAutoTone, boxBlurRGB as _boxBlurRGB, skinMaskFromData as _skinMaskFromData, frequencySeparationSmooth as _frequencySeparationSmooth, blemishRemoval as _blemishRemoval, teethWhitening as _teethWhitening, featureExclusionMask as _featureExclusionMask, dilateAlpha as _dilateAlpha, subtractMask as _subtractMask, guidedClarity as _guidedClarity } from './editor/retouch.js';
import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js'; import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js';
import { wireAIModelSelectors, resolveInpaintModel, resolveQuickEditModel, resolveQuickEditAgentModel } from './editor/ai-models.js'; import { wireAIModelSelectors, resolveInpaintModel, resolveQuickEditModel, resolveQuickEditAgentModel } from './editor/ai-models.js';
import { createPreflight } from './editor/preflight.js';
import { createAiSettings } from './editor/ai-settings.js'; import { createAiSettings } from './editor/ai-settings.js';
import { waitForImagePreferences } from './settings/imagePreferences.js';
import { createImageGenerationAction } from './editor/image-generation.js';
import { finishQuickEditJob } from './editor/quick-edit-job.js';
import { imageResultGeometry } from './editor/image-result-geometry.js';
import { lookRecipeSteps } from './editor/look-recipes.js'; import { lookRecipeSteps } from './editor/look-recipes.js';
import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1'; import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1';
import { wireAIToolsMisc } from './editor/ai-tools-misc.js'; import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
@@ -356,7 +359,6 @@ function _computeTransformSnap(frame, center) {
const _INPAINT_DEFAULT_BRUSH = 100; const _INPAINT_DEFAULT_BRUSH = 100;
let _samAbortController = null; let _samAbortController = null;
let _precisionGuides = null; let _precisionGuides = null;
let _preflight = null;
let _aiSettings = null; let _aiSettings = null;
let _selectionAnimationFrame = null; let _selectionAnimationFrame = null;
let _selectionAnimationLast = 0; let _selectionAnimationLast = 0;
@@ -394,6 +396,12 @@ if (!window.__galleryEditEscHardGuardInstalled) {
} }
if (e.target?.closest?.('#styled-confirm-overlay')) return; if (e.target?.closest?.('#styled-confirm-overlay')) return;
if (e.key === 'Escape') { if (e.key === 'Escape') {
const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])');
if (aiSettings) {
e.preventDefault(); e.stopImmediatePropagation();
aiSettings.querySelector('[data-ai-settings-close]')?.click();
return;
}
// Floating dialogs/popups that live outside the editor modal must close on // Floating dialogs/popups that live outside the editor modal must close on
// Escape reliably (their document-capture handlers can miss when focus is // Escape reliably (their document-capture handlers can miss when focus is
// outside them). Close the top-most one and stop here. // outside them). Close the top-most one and stop here.
@@ -838,6 +846,7 @@ function _setCanvasAiBusy(busy) {
} }
let _aiProgressTimer = null; let _aiProgressTimer = null;
let _aiProgressEpoch = 0;
function _setAiProgress(frac, label) { function _setAiProgress(frac, label) {
const wrap = document.getElementById('ge-ai-command-progress'); const wrap = document.getElementById('ge-ai-command-progress');
if (!wrap) return; if (!wrap) return;
@@ -857,12 +866,17 @@ function _startAiProgress(requestId) {
const endpointId = arguments[1] || ''; const endpointId = arguments[1] || '';
_stopAiProgress(); _stopAiProgress();
if (!requestId) return; if (!requestId) return;
const epoch = _aiProgressEpoch;
let pending = false;
const tick = async () => { const tick = async () => {
if (pending || epoch !== _aiProgressEpoch) return;
pending = true;
try { try {
const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : ''; const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' }); const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
if (!r.ok) return; if (!r.ok) return;
const p = await r.json(); const p = await r.json();
if (epoch !== _aiProgressEpoch) return;
const st = p.stage || ''; const st = p.stage || '';
if (st === 'done' || st === 'error') { _setAiProgress(1, st === 'done' ? 'Done' : 'Failed'); return; } if (st === 'done' || st === 'error') { _setAiProgress(1, st === 'done' ? 'Done' : 'Failed'); return; }
const frac = typeof p.progress === 'number' ? p.progress : 0; const frac = typeof p.progress === 'number' ? p.progress : 0;
@@ -871,12 +885,13 @@ function _startAiProgress(requestId) {
const verb = st === 'editing' ? 'Generating' : (st ? st[0].toUpperCase() + st.slice(1) : 'Working'); const verb = st === 'editing' ? 'Generating' : (st ? st[0].toUpperCase() + st.slice(1) : 'Working');
_setAiProgress(frac, `${verb}…${stepTxt}${eta}`); _setAiProgress(frac, `${verb}…${stepTxt}${eta}`);
_setAiCommandStatus(`${verb}…${stepTxt}${eta}`, 'running'); _setAiCommandStatus(`${verb}…${stepTxt}${eta}`, 'running');
} catch (_) {} } catch (_) {} finally { pending = false; }
}; };
_aiProgressTimer = setInterval(tick, 1000); _aiProgressTimer = setInterval(tick, 1000);
tick(); tick();
} }
function _stopAiProgress() { function _stopAiProgress() {
_aiProgressEpoch++;
if (_aiProgressTimer) { clearInterval(_aiProgressTimer); _aiProgressTimer = null; } if (_aiProgressTimer) { clearInterval(_aiProgressTimer); _aiProgressTimer = null; }
_setAiProgress(null); _setAiProgress(null);
} }
@@ -1074,6 +1089,7 @@ const _QUICK_EDIT_TOOL_SUGGESTIONS = Object.entries(_TOOLS).map(([id, t]) => ({
kind: 'Tool', kind: 'Tool',
icon: t.icon || '', icon: t.icon || '',
toolId: id, toolId: id,
runPrompt: id === 'rembg' ? 'remove background' : null,
aliases: [id], aliases: [id],
})); }));
const _QUICK_EDIT_CATALOG = [..._QUICK_EDIT_TOOL_SUGGESTIONS, ..._AI_COMMAND_SUGGESTIONS]; const _QUICK_EDIT_CATALOG = [..._QUICK_EDIT_TOOL_SUGGESTIONS, ..._AI_COMMAND_SUGGESTIONS];
@@ -1281,8 +1297,13 @@ function _wireAiCommandBox() {
if (item.toolId) { if (item.toolId) {
_clickToolButton(item.toolId); _clickToolButton(item.toolId);
_setAiCommandStatus(`${item.label} tool`, 'done'); _setAiCommandStatus(`${item.label} tool`, 'done');
if (item.runPrompt) {
input.value = item.runPrompt;
syncClearButton();
}
hideSuggestions(); hideSuggestions();
input.focus(); input.focus();
if (run && item.runPrompt) form.requestSubmit();
return true; return true;
} }
input.value = item.insert; input.value = item.insert;
@@ -1471,6 +1492,24 @@ function _wireAiCommandBox() {
// model (window.geEditorAI) just like the server would route it. // model (window.geEditorAI) just like the server would route it.
const _executeQuickEditLocal = async (prompt) => { const _executeQuickEditLocal = async (prompt) => {
const p = prompt.toLowerCase(); const p = prompt.toLowerCase();
// Background-removal intent (shared with the routing guard so the regex
// can never drift between the two call sites).
if (_looksBackgroundRemoval(p)) {
await _runQuickControlOp('ai.removeBackground', {});
// Chain a tone step when the prompt also asks for one
// ("remove the background and make it brighter", "...and warm it up").
if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } });
} else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } });
} else if (/\b(warm(?:er)?)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } });
} else if (/\b(cool(?:er)?)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } });
}
_setAiCommandStatus('Background removal finished.', 'done');
return;
}
// A targeted recolour ("make only the red car blue", "recolour the rock to // A targeted recolour ("make only the red car blue", "recolour the rock to
// teal") needs the model to repaint a specific object — never fake it with a // teal") needs the model to repaint a specific object — never fake it with a
// global colour tweak from the deterministic fast path. // global colour tweak from the deterministic fast path.
@@ -1933,24 +1972,6 @@ function _wireAiCommandBox() {
return; return;
} }
} }
// Background-removal intent (shared with the routing guard so the regex
// can never drift between the two call sites).
if (_looksBackgroundRemoval(p)) {
await _runQuickControlOp('ai.removeBackground', {});
// Chain a tone step when the prompt also asks for one
// ("remove the background and make it brighter", "...and warm it up").
if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } });
} else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } });
} else if (/\b(warm(?:er)?)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } });
} else if (/\b(cool(?:er)?)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } });
}
_setAiCommandStatus('Background removal finished.', 'done');
return;
}
if (!/\b(text|title|caption|font|heading|subtitle)\b/.test(p) if (!/\b(text|title|caption|font|heading|subtitle)\b/.test(p)
&& /\b(upscale|higher\s*res|increase\s*resolution|bigger|2x|4x)\b/.test(p)) { && /\b(upscale|higher\s*res|increase\s*resolution|bigger|2x|4x)\b/.test(p)) {
// Use the real upscale op (Real-ESRGAN, basic-upscale fallback). The // Use the real upscale op (Real-ESRGAN, basic-upscale fallback). The
@@ -2814,8 +2835,8 @@ function _wireAiCommandBox() {
if (_looksBackgroundRemoval(prompt)) { if (_looksBackgroundRemoval(prompt)) {
_setAiCommandStatus('Removing the background…', 'running'); _setAiCommandStatus('Removing the background…', 'running');
try { try {
const done = await _executeQuickEditLocal(prompt); await _executeQuickEditLocal(prompt);
if (done !== false) return { ok: true }; return { ok: true };
} catch (err) { } catch (err) {
if (signal && signal.aborted) { _setAiCommandStatus('', ''); return { ok: false, cancelled: true }; } if (signal && signal.aborted) { _setAiCommandStatus('', ''); return { ok: false, cancelled: true }; }
const msg = err && err.message ? err.message : String(err); const msg = err && err.message ? err.message : String(err);
@@ -2841,6 +2862,7 @@ function _wireAiCommandBox() {
_setAiCommandStatus('Generated an image.', 'done'); _setAiCommandStatus('Generated an image.', 'done');
return { ok: true, image: res }; return { ok: true, image: res };
} catch (err) { } catch (err) {
if (signal?.aborted) return { ok: false, cancelled: true };
const msg = err && err.message ? err.message : String(err); const msg = err && err.message ? err.message : String(err);
_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error'); _setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');
return { ok: false, error: msg }; return { ok: false, error: msg };
@@ -3083,19 +3105,22 @@ function _wireAiCommandBox() {
job.controller = controller; job.controller = controller;
renderJobStack(); renderJobStack();
_quickEditActiveJob = job; _quickEditActiveJob = job;
let result;
try { try {
await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null }); result = await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null });
} catch (err) { } catch (err) {
if (!job.cancelled) { if (!job.cancelled) {
job.status = 'error'; job.status = 'error';
job.detail = err?.message || 'AI edit failed'; job.detail = err?.message || 'AI edit failed';
} }
} finally { } finally {
_stopAiProgress();
finishQuickEditJob(job, result);
_setAiCommandStatus(job.cancelled ? '' : job.detail, job.cancelled ? '' : job.status);
if (_quickEditActiveJob === job) _quickEditActiveJob = null; if (_quickEditActiveJob === job) _quickEditActiveJob = null;
job.onChange = null; job.onChange = null;
job.controller = null; job.controller = null;
if (job.cancelled) continue; if (job.cancelled) continue;
if (job.status === 'running') job.status = 'done';
renderJobStack(); renderJobStack();
setTimeout(() => dismissJob(job), job.status === 'error' ? 3200 : 1600); setTimeout(() => dismissJob(job), job.status === 'error' ? 3200 : 1600);
} }
@@ -5304,9 +5329,14 @@ function _restoreState(snap) {
// inside _buildEditor, so we touch the DOM here). // inside _buildEditor, so we touch the DOM here).
const sizeLabel = document.getElementById('ge-canvas-size'); const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight} px`; if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight} px`;
state.rembgLiveLayer = null;
state.rembgLiveSnap = null;
} }
function undo() { function undo() {
// End sampling before restoring pixels; pointer-up must not replay the
// unfinished stroke into the restored layer without a new history entry.
if (state.drawing) _strokeTool.tryEnd();
if (state.undoStack.length === 0) return; if (state.undoStack.length === 0) return;
const target = state.undoStack.pop(); const target = state.undoStack.pop();
const cur = _snapshotState(); const cur = _snapshotState();
@@ -6535,6 +6565,11 @@ const _nudgeTransform = _transformSession.nudgeTransform;
const _confirmTransform = _transformSession.confirmTransform; const _confirmTransform = _transformSession.confirmTransform;
const _cancelTransform = _transformSession.cancelTransform; const _cancelTransform = _transformSession.cancelTransform;
window.__galleryEditorHandleEscape = () => { window.__galleryEditorHandleEscape = () => {
const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])');
if (aiSettings) {
aiSettings.querySelector('[data-ai-settings-close]')?.click();
return true;
}
const sizePrompt = document.getElementById('ge-canvas-size-overlay'); const sizePrompt = document.getElementById('ge-canvas-size-overlay');
if (sizePrompt && getComputedStyle(sizePrompt).display !== 'none') { if (sizePrompt && getComputedStyle(sizePrompt).display !== 'none') {
sizePrompt._cancelCanvasSize?.(); sizePrompt._cancelCanvasSize?.();
@@ -8064,6 +8099,7 @@ async function _canMutateLayerPixels(layer, action = 'editing pixels') {
return false; return false;
} }
if (!['placed', 'text', 'shape'].includes(layer.kind)) return true; if (!['placed', 'text', 'shape'].includes(layer.kind)) return true;
if (action === 'cutting pixels') return _rasterizeLayerNow(layer);
return _confirmRasterizeLayer(layer); return _confirmRasterizeLayer(layer);
} }
@@ -8262,10 +8298,13 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
// standard slim red-thumb styling instead of the bare browser // standard slim red-thumb styling instead of the bare browser
// default. Value chip on the same line as the label. // default. Value chip on the same line as the label.
const isColor = p.type === 'color'; const isColor = p.type === 'color';
const isSelect = p.type === 'select';
rows += ` rows += `
<div class="ge-filter-row ge-eraser-row"> <div class="ge-filter-row ge-eraser-row">
<label>${p.label}${isColor ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label> <label>${p.label}${isColor || isSelect ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
${isColor ${isSelect
? `<select data-key="${p.key}" class="ge-ctl-select" aria-label="${p.label}">${p.options.map(option => `<option value="${option.value}"${String(option.value) === String(p.value) ? ' selected' : ''}>${option.label}</option>`).join('')}</select>`
: isColor
? `<input type="color" data-key="${p.key}" value="${p.value}" aria-label="${p.label}" />` ? `<input type="color" data-key="${p.key}" value="${p.value}" aria-label="${p.label}" />`
: `<input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step || 1}" value="${p.value}" />`} : `<input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step || 1}" value="${p.value}" />`}
</div> </div>
@@ -8286,7 +8325,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
for (const p of params) values[p.key] = p.value; for (const p of params) values[p.key] = p.value;
// Initial preview render. // Initial preview render.
try { onPreview(values); } catch {} try { onPreview(values); } catch {}
overlay.querySelectorAll('input[data-key]').forEach(inp => { overlay.querySelectorAll('input[data-key], select[data-key]').forEach(inp => {
inp.addEventListener('input', (e) => { inp.addEventListener('input', (e) => {
if (!isCurrent()) { cleanup(null); return; } if (!isCurrent()) { cleanup(null); return; }
const k = e.target.dataset.key; const k = e.target.dataset.key;
@@ -8317,7 +8356,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
e.preventDefault(); e.preventDefault();
cleanup(e.target?.dataset.action === 'cancel' ? null : values); cleanup(e.target?.dataset.action === 'cancel' ? null : values);
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
const fields = [...overlay.querySelectorAll('input, button')]; const fields = [...overlay.querySelectorAll('input, select, button')];
const index = fields.indexOf(document.activeElement); const index = fields.indexOf(document.activeElement);
e.preventDefault(); e.preventDefault();
fields[(index + (e.shiftKey ? -1 : 1) + fields.length) % fields.length]?.focus(); fields[(index + (e.shiftKey ? -1 : 1) + fields.length) % fields.length]?.focus();
@@ -8329,7 +8368,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
if (!overlay.isConnected || !isCurrent()) cleanup(null); if (!overlay.isConnected || !isCurrent()) cleanup(null);
}); });
observer.observe(state.container, { childList: true, subtree: true }); observer.observe(state.container, { childList: true, subtree: true });
overlay.querySelector('input, button')?.focus({ preventScroll: true }); overlay.querySelector('input, select, button')?.focus({ preventScroll: true });
overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values)); overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values));
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null)); overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null));
// Click outside the modal (on the dim backdrop) = cancel. // Click outside the modal (on the dim backdrop) = cancel.
@@ -8495,6 +8534,7 @@ function _shadowOffsets(values = {}) {
return { return {
x: Math.round(Math.cos(radians) * distance), x: Math.round(Math.cos(radians) * distance),
y: Math.round(-Math.sin(radians) * distance), y: Math.round(-Math.sin(radians) * distance),
inside: Number(values.inside) === 1,
}; };
} }
@@ -8556,6 +8596,7 @@ async function _addRetainedEffect(type, presetName = null) {
{ key: 'color', label: 'Color', type: 'color', value: '#000000' }, { key: 'color', label: 'Color', type: 'color', value: '#000000' },
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: 45, suffix: '%' }, { key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: 45, suffix: '%' },
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: 12, suffix: 'px' }, { key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: 12, suffix: 'px' },
{ key: 'inside', label: 'Position', type: 'select', value: 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] },
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).distance, suffix: 'px' }, { key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).distance, suffix: 'px' },
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).angle, suffix: '\u00b0' }, { key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).angle, suffix: '\u00b0' },
]; ];
@@ -8769,6 +8810,7 @@ async function _editRetainedEffect(layer, effect) {
{ key: 'color', label: 'Color', type: 'color', value: p.color || '#000000' }, { key: 'color', label: 'Color', type: 'color', value: p.color || '#000000' },
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: Math.round((Number(p.opacity) || 0) * 100), suffix: '%' }, { key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: Math.round((Number(p.opacity) || 0) * 100), suffix: '%' },
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: Number(p.blur) || 0, suffix: 'px' }, { key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: Number(p.blur) || 0, suffix: 'px' },
{ key: 'inside', label: 'Position', type: 'select', value: p.inside ? 1 : 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] },
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance(p).distance, suffix: 'px' }, { key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance(p).distance, suffix: 'px' },
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance(p).angle, suffix: '\u00b0' }, { key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance(p).angle, suffix: '\u00b0' },
]; ];
@@ -9993,9 +10035,8 @@ function _buildEditor(container) {
if (colorRow) colorRow.style.display = ['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify'].includes(toolId) ? 'none' : ''; if (colorRow) colorRow.style.display = ['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify'].includes(toolId) ? 'none' : '';
const colorLabel = colorRow?.querySelector('label'); const colorLabel = colorRow?.querySelector('label');
if (colorLabel) colorLabel.textContent = 'Color'; if (colorLabel) colorLabel.textContent = 'Color';
// Per-tool stroke modifiers all live in the shared brush "More" menu so // Secondary modifiers stay in More; brush softness remains in the main
// every brush-family tool has the same compact strip as the Brush. Only // strip. Only the active tool's secondary group is shown.
// the group for the active tool is shown; the rest stay hidden.
const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush' const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush'
: toolId === 'eraser' ? 'eraser' : toolId === 'eraser' ? 'eraser'
: (toolId === 'clone' || toolId === 'heal') ? 'clone' : (toolId === 'clone' || toolId === 'heal') ? 'clone'
@@ -10005,6 +10046,8 @@ function _buildEditor(container) {
row.style.display = row.dataset.strokeTool === strokeTool ? '' : 'none'; row.style.display = row.dataset.strokeTool === strokeTool ? '' : 'none';
}); });
const dodgeBurnRow = document.getElementById('ge-dodgeburn-intensity-row'); const dodgeBurnRow = document.getElementById('ge-dodgeburn-intensity-row');
const brushSoftnessRow = document.getElementById('ge-brush-softness-row');
if (brushSoftnessRow) brushSoftnessRow.style.display = ['brush', 'dodge', 'burn'].includes(toolId) ? '' : 'none';
// Intensity sits in the strip next to Size, so it must show for Dodge and // Intensity sits in the strip next to Size, so it must show for Dodge and
// Burn only (and never for the other brush-family tools). // Burn only (and never for the other brush-family tools).
if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none'; if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none';
@@ -10457,7 +10500,6 @@ function _buildEditor(container) {
canvasArea.appendChild(state.transformOverlay); canvasArea.appendChild(state.transformOverlay);
canvasArea.appendChild(_buildQuickMaskBar()); canvasArea.appendChild(_buildQuickMaskBar());
canvasArea.appendChild(_buildAiCommandBox()); canvasArea.appendChild(_buildAiCommandBox());
_preflight = createPreflight({ canvasArea });
_aiSettings = createAiSettings({ container }); _aiSettings = createAiSettings({ container });
try { window.geEditorAiSettings = _aiSettings; } catch (_) {} try { window.geEditorAiSettings = _aiSettings; } catch (_) {}
// On phones the rulers eat scarce canvas width; default them off. // On phones the rulers eat scarce canvas width; default them off.
@@ -10650,19 +10692,10 @@ function _buildEditor(container) {
const initialStatusTool = document.getElementById('ge-status-tool'); const initialStatusTool = document.getElementById('ge-status-tool');
if (initialStatusTool) initialStatusTool.textContent = _TOOLS[state.tool]?.label || state.tool || ''; if (initialStatusTool) initialStatusTool.textContent = _TOOLS[state.tool]?.label || state.tool || '';
} }
// Preflight checklist: greet the user on open (unless dismissed) and // Keep model configuration in the AI settings dialog.
// expose a footer button to re-open it.
try { try {
const footer = container.querySelector('.ge-editor-footer'); const footer = container.querySelector('.ge-editor-footer');
if (footer) { if (footer) {
const setupBtn = document.createElement('button');
setupBtn.type = 'button';
setupBtn.id = 'ge-setup-btn';
setupBtn.className = 'ge-setup-btn';
setupBtn.title = 'Editor setup checklist';
setupBtn.textContent = 'Setup';
setupBtn.addEventListener('click', () => _preflight?.open());
footer.appendChild(setupBtn);
const aiBtn = document.createElement('button'); const aiBtn = document.createElement('button');
aiBtn.type = 'button'; aiBtn.type = 'button';
aiBtn.id = 'ge-ai-settings-btn'; aiBtn.id = 'ge-ai-settings-btn';
@@ -10672,7 +10705,6 @@ function _buildEditor(container) {
aiBtn.addEventListener('click', () => _aiSettings?.open()); aiBtn.addEventListener('click', () => _aiSettings?.open());
footer.appendChild(aiBtn); footer.appendChild(aiBtn);
} }
_preflight?.maybeOpen();
} catch (_) {} } catch (_) {}
try { _initControlKit(container); } catch (_) {} try { _initControlKit(container); } catch (_) {}
let _lastLayoutDesktop = null; let _lastLayoutDesktop = null;
@@ -11792,6 +11824,14 @@ function _buildEditor(container) {
// Keyboard shortcuts — full implementation in // Keyboard shortcuts — full implementation in
// editor/keyboard-shortcuts.js. // editor/keyboard-shortcuts.js.
wireKeyboardShortcuts({ wireKeyboardShortcuts({
openLayerAdjustment: (type) => {
const layer = activeLayer();
if (!layer || !layer.canvas || layer.kind === 'adjustment') {
uiModule?.showToast('Select an image layer first');
return;
}
_openAdjPopup(layer, type, document.getElementById('ge-image-menu-btn'));
},
fillSelection: _doFillSelection, fillSelection: _doFillSelection,
copyPixelsToClipboard: _copyPixelsToClipboard, copyPixelsToClipboard: _copyPixelsToClipboard,
toolbar, toolKeyMap: _toolKeyMap, toolbar, toolKeyMap: _toolKeyMap,
@@ -12156,7 +12196,9 @@ function _buildEditor(container) {
out.getContext('2d').drawImage(layer.canvas, off.x, off.y); out.getContext('2d').drawImage(layer.canvas, off.x, off.y);
return out; return out;
}; };
const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = false } = {}) => { const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = true } = {}) => {
await waitForImagePreferences();
if (endpoint === '/api/image/inpaint') payload = { ...payload, _use_image_settings: true };
const flat = _aiEditSourceCanvas(); const flat = _aiEditSourceCanvas();
const image = flat.toDataURL('image/png').split(',')[1]; const image = flat.toDataURL('image/png').split(',')[1];
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
@@ -12167,7 +12209,7 @@ function _buildEditor(container) {
} else { } else {
_startAiProgress(requestId); _startAiProgress(requestId);
} }
const body = JSON.stringify({ image, request_id: requestId, ...payload }); const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });
// The image servers occasionally reject a request transiently (503 while a // The image servers occasionally reject a request transiently (503 while a
// model swaps, 502/504 gateways, dropped connections). Retry those a couple // model swaps, 502/504 gateways, dropped connections). Retry those a couple
// of times with backoff so a hiccup doesn't silently leave the canvas // of times with backoff so a hiccup doesn't silently leave the canvas
@@ -12225,7 +12267,7 @@ function _buildEditor(container) {
c.height = state.imgHeight; c.height = state.imgHeight;
} }
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
return { canvas: c, dataUrl: c.toDataURL('image/png') }; return { canvas: c, dataUrl: c.toDataURL('image/png'), fullFrame: data.full_frame === true };
} finally { } finally {
if (_timer) clearTimeout(_timer); if (_timer) clearTimeout(_timer);
if (_aiEditAbort === _ctl) _aiEditAbort = null; if (_aiEditAbort === _ctl) _aiEditAbort = null;
@@ -12234,12 +12276,17 @@ function _buildEditor(container) {
}; };
const _placeResultLayer = (canvas, name, maskCanvas) => { const _placeResultLayer = (canvas, name, maskCanvas) => {
_saveState(`AI: ${name}`); _saveState(`AI: ${name}`);
const layer = createLayer(name, state.imgWidth, state.imgHeight); const geometry = imageResultGeometry(canvas.width, canvas.height, state.imgWidth, state.imgHeight);
layer.ctx.drawImage(canvas, 0, 0); const layer = createLayer(name, geometry.width, geometry.height);
const offset = { x: geometry.offsetX, y: geometry.offsetY };
state.layerOffsets.set(layer.id, offset);
layer.ctx.imageSmoothingEnabled = true;
layer.ctx.imageSmoothingQuality = 'high';
layer.ctx.drawImage(canvas, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
if (maskCanvas) { if (maskCanvas) {
layer.ctx.save(); layer.ctx.save();
layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.globalCompositeOperation = 'destination-in';
layer.ctx.drawImage(maskCanvas, 0, 0); layer.ctx.drawImage(maskCanvas, -offset.x, -offset.y);
layer.ctx.restore(); layer.ctx.restore();
} }
state.layers.push(layer); state.layers.push(layer);
@@ -12249,13 +12296,24 @@ function _buildEditor(container) {
_renderLayerPanel(); _renderLayerPanel();
return layer.id; return layer.id;
}; };
const _generateImage = createImageGenerationAction({
formatError: _generationErrorMessage,
getDocument: () => ({
key: state.editorOpen ? `${state.editorSessionToken}:${state.documentId}` : null,
width: state.imgWidth, height: state.imgHeight, mask: state.wandMask,
}),
getSignal: () => _quickEditActiveJob?.controller?.signal,
onProgress: seconds => _setAiCommandStatus(`Generating an image… ${seconds}s`, 'running'),
placeResult: _placeResultLayer,
});
const _aiInpaintSelection = async (prompt) => { const _aiInpaintSelection = async (prompt) => {
if (!state.wandMask) throw new Error('make a selection first'); if (!state.wandMask) throw new Error('make a selection first');
const layer = activeLayer() || _activeParentLayer(); const layer = activeLayer() || _activeParentLayer();
if (!layer) throw new Error('no layer'); if (!layer) throw new Error('no layer');
const mask = state.wandMask.toDataURL('image/png').split(',')[1]; const mask = state.wandMask.toDataURL('image/png').split(',')[1];
const { canvas } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 }); const selection = state.wandMask;
return _placeResultLayer(canvas, 'Generative fill', state.wandMask); const { canvas, fullFrame } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 });
return _placeResultLayer(canvas, 'Generative fill', fullFrame ? null : selection);
}; };
// Replace the background/scene while keeping the subject: the server derives // Replace the background/scene while keeping the subject: the server derives
// the subject mask, repaints the inverse (new background), harmonises colour // the subject mask, repaints the inverse (new background), harmonises colour
@@ -12548,7 +12606,7 @@ function _buildEditor(container) {
'layers.delete': { description: 'Delete layers (defaults to selection).', run: (p) => { if (p && (p.id || p.index || p.name)) _controlSelect(_controlFindLayer(p)); _layerPanelRenderer.deleteSelectedLayers?.(); return true; } }, 'layers.delete': { description: 'Delete layers (defaults to selection).', run: (p) => { if (p && (p.id || p.index || p.name)) _controlSelect(_controlFindLayer(p)); _layerPanelRenderer.deleteSelectedLayers?.(); return true; } },
'layers.setOpacity': { description: 'Set a layer opacity (0-1).', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change opacity of ${l.name}`); l.opacity = Math.max(0, Math.min(1, Number(p.opacity))); composite(); _renderLayerPanel(); return l.id; } }, 'layers.setOpacity': { description: 'Set a layer opacity (0-1).', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change opacity of ${l.name}`); l.opacity = Math.max(0, Math.min(1, Number(p.opacity))); composite(); _renderLayerPanel(); return l.id; } },
'layers.setBlend': { description: 'Set a layer blend mode.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change blend mode of ${l.name}`); l.blendMode = p.blendMode || p.mode || 'source-over'; composite(); _renderLayerPanel(); return l.id; } }, 'layers.setBlend': { description: 'Set a layer blend mode.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change blend mode of ${l.name}`); l.blendMode = p.blendMode || p.mode || 'source-over'; composite(); _renderLayerPanel(); return l.id; } },
'layers.placeImage': { description: 'Place an image (data URL) as a NEW layer, masked to the current selection when one exists; never overwrites the original. Instruction mode (mode:"instruction" or differenceMask:true) keeps untouched areas pixel-identical via a difference map. {dataUrl,name,masked,blend,opacity,x,y,w,h,mode,differenceMask,diffThreshold,diffGrow,diffFeather}.', run: (p) => new Promise((resolve, reject) => { if (!p || !p.dataUrl) { reject(new Error('dataUrl required')); return; } const img = new Image(); img.onload = () => { try { _saveState(`Place ${p.name || 'image'}`); const dw = state.imgWidth, dh = state.imgHeight; const useDiff = (p.differenceMask === true) || String(p.mode || '').toLowerCase() === 'instruction'; const beforeSnap = useDiff ? flatten() : null; const layer = createLayer(p.name || 'Placed image', dw, dh); let tw = Number(p.w), th = Number(p.h); if (!(tw > 0) || !(th > 0)) { if (useDiff) { tw = dw; th = dh; } else { const scale = Math.min(dw / img.naturalWidth, dh / img.naturalHeight); tw = Math.max(1, Math.round(img.naturalWidth * scale)); th = Math.max(1, Math.round(img.naturalHeight * scale)); } } let tx = Number(p.x), ty = Number(p.y); if (!Number.isFinite(tx)) tx = useDiff ? 0 : Math.round((dw - tw) / 2); if (!Number.isFinite(ty)) ty = useDiff ? 0 : Math.round((dh - th) / 2); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, tx, ty, tw, th); if (p.masked !== false && state.wandMask) { layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(state.wandMask, 0, 0); layer.ctx.restore(); } else if (useDiff && beforeSnap) { const diff = _differenceMaskCanvas(beforeSnap, layer.canvas, { threshold: p.diffThreshold, grow: p.diffGrow, feather: p.diffFeather, edgeExcludePx: 0 }); const explicitEdge = p.edgeExcludePx != null ? Number(p.edgeExcludePx) : null; const sceneReplace = diff.changedFraction > 0.5; const edgeExclude = sceneReplace ? 0 : (explicitEdge != null ? explicitEdge : 3); if (edgeExclude > 0) _clearMaskEdgeBand(diff.mask, edgeExclude); layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(diff.mask, 0, 0); layer.ctx.restore(); if (sceneReplace) _edgeFillFromInterior(layer.ctx, dw, dh, 3); } if (p.blend) layer.blendMode = p.blend; if (p.opacity != null) layer.opacity = Math.max(0, Math.min(1, Number(p.opacity))); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; _renderLayerPanel(); composite(); resolve(layer.id); } catch (e) { reject(e); } }; img.onerror = () => reject(new Error('image load failed')); img.src = p.dataUrl; }) }, 'layers.placeImage': { description: 'Place an image (data URL) as a NEW layer, masked to the current selection when one exists; never overwrites the original. Instruction mode (mode:"instruction" or differenceMask:true) keeps untouched areas pixel-identical via a difference map. {dataUrl,name,masked,blend,opacity,x,y,w,h,mode,differenceMask,diffThreshold,diffGrow,diffFeather}.', run: (p) => new Promise((resolve, reject) => { if (!p || !p.dataUrl) { reject(new Error('dataUrl required')); return; } const img = new Image(); img.onload = () => { try { _saveState(`Place ${p.name || 'image'}`); const dw = state.imgWidth, dh = state.imgHeight; const useDiff = p.nativeSize !== true && ((p.differenceMask === true) || String(p.mode || '').toLowerCase() === 'instruction'); const beforeSnap = useDiff ? flatten() : null; const nativeSize = p.nativeSize === true && !useDiff; const geometry = imageResultGeometry(img.naturalWidth, img.naturalHeight, dw, dh); const layer = createLayer(p.name || 'Placed image', nativeSize ? geometry.width : dw, nativeSize ? geometry.height : dh); const nativeOffset = { x: geometry.offsetX, y: geometry.offsetY }; if (nativeSize) state.layerOffsets.set(layer.id, nativeOffset); let tw = Number(p.w), th = Number(p.h); if (!(tw > 0) || !(th > 0)) { if (nativeSize) { tw = geometry.drawWidth; th = geometry.drawHeight; } else if (useDiff) { tw = dw; th = dh; } else { const scale = Math.min(dw / img.naturalWidth, dh / img.naturalHeight); tw = Math.max(1, Math.round(img.naturalWidth * scale)); th = Math.max(1, Math.round(img.naturalHeight * scale)); } } let tx = Number(p.x), ty = Number(p.y); if (!Number.isFinite(tx)) tx = nativeSize ? geometry.drawX : useDiff ? 0 : Math.round((dw - tw) / 2); if (!Number.isFinite(ty)) ty = nativeSize ? geometry.drawY : useDiff ? 0 : Math.round((dh - th) / 2); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, tx, ty, tw, th); if (p.masked !== false && state.wandMask) { layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(state.wandMask, nativeSize ? -nativeOffset.x : 0, nativeSize ? -nativeOffset.y : 0); layer.ctx.restore(); } else if (useDiff && beforeSnap) { const diff = _differenceMaskCanvas(beforeSnap, layer.canvas, { threshold: p.diffThreshold, grow: p.diffGrow, feather: p.diffFeather, edgeExcludePx: 0 }); const explicitEdge = p.edgeExcludePx != null ? Number(p.edgeExcludePx) : null; const sceneReplace = diff.changedFraction > 0.5; const edgeExclude = sceneReplace ? 0 : (explicitEdge != null ? explicitEdge : 3); if (edgeExclude > 0) _clearMaskEdgeBand(diff.mask, edgeExclude); layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(diff.mask, 0, 0); layer.ctx.restore(); if (sceneReplace) _edgeFillFromInterior(layer.ctx, dw, dh, 3); } if (p.blend) layer.blendMode = p.blend; if (p.opacity != null) layer.opacity = Math.max(0, Math.min(1, Number(p.opacity))); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; _renderLayerPanel(); composite(); resolve(layer.id); } catch (e) { reject(e); } }; img.onerror = () => reject(new Error('image load failed')); img.src = p.dataUrl; }) },
'layers.addMaskFromSelection': { description: 'Add a layer mask from the current selection (reveals the selection). {invert}.', run: (p) => { const parent = _activeParentLayer(); if (!parent) throw new Error('no layer'); _saveState('Add layer mask'); const mask = _ensureActiveMaskLayer(); if (!mask) throw new Error('could not create mask'); const w = mask.canvas.width, h = mask.canvas.height; const mctx = mask.ctx; const off = state.layerOffsets.get(parent.id) || { x: 0, y: 0 }; const ox = -(off.x || 0), oy = -(off.y || 0); mctx.globalCompositeOperation = 'source-over'; mctx.clearRect(0, 0, w, h); if (state.wandMask) { if (p && p.invert) { mctx.fillStyle = '#fff'; mctx.fillRect(0, 0, w, h); mctx.globalCompositeOperation = 'destination-out'; mctx.drawImage(state.wandMask, ox, oy); mctx.globalCompositeOperation = 'source-over'; } else { mctx.drawImage(state.wandMask, ox, oy); } } else { mctx.fillStyle = (p && p.invert) ? '#000' : '#fff'; mctx.fillRect(0, 0, w, h); } composite(); _renderLayerPanel(); return mask.id; } }, 'layers.addMaskFromSelection': { description: 'Add a layer mask from the current selection (reveals the selection). {invert}.', run: (p) => { const parent = _activeParentLayer(); if (!parent) throw new Error('no layer'); _saveState('Add layer mask'); const mask = _ensureActiveMaskLayer(); if (!mask) throw new Error('could not create mask'); const w = mask.canvas.width, h = mask.canvas.height; const mctx = mask.ctx; const off = state.layerOffsets.get(parent.id) || { x: 0, y: 0 }; const ox = -(off.x || 0), oy = -(off.y || 0); mctx.globalCompositeOperation = 'source-over'; mctx.clearRect(0, 0, w, h); if (state.wandMask) { if (p && p.invert) { mctx.fillStyle = '#fff'; mctx.fillRect(0, 0, w, h); mctx.globalCompositeOperation = 'destination-out'; mctx.drawImage(state.wandMask, ox, oy); mctx.globalCompositeOperation = 'source-over'; } else { mctx.drawImage(state.wandMask, ox, oy); } } else { mctx.fillStyle = (p && p.invert) ? '#000' : '#fff'; mctx.fillRect(0, 0, w, h); } composite(); _renderLayerPanel(); return mask.id; } },
'layers.removeMask': { description: 'Remove the active layer mask (does not affect pixels).', run: () => { const parent = _activeParentLayer(); if (!parent || !parent.masks || !parent.masks.length) throw new Error('no mask'); _saveState('Remove layer mask'); parent.masks = parent.masks.filter((m) => m.id !== parent.activeMaskId); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } }, 'layers.removeMask': { description: 'Remove the active layer mask (does not affect pixels).', run: () => { const parent = _activeParentLayer(); if (!parent || !parent.masks || !parent.masks.length) throw new Error('no mask'); _saveState('Remove layer mask'); parent.masks = parent.masks.filter((m) => m.id !== parent.activeMaskId); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } },
'layers.applyMask': { description: 'Bake the active layer mask into the layer alpha and remove the mask.', run: () => { const parent = _activeParentLayer(); const mask = _getActiveMaskLayer(); if (!parent || !parent.ctx || !mask) throw new Error('no mask to apply'); _saveState('Apply layer mask'); _bakeMaskIntoLayer(parent.ctx, mask.canvas, 0, 0); parent.masks = parent.masks.filter((m) => m.id !== mask.id); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } }, 'layers.applyMask': { description: 'Bake the active layer mask into the layer alpha and remove the mask.', run: () => { const parent = _activeParentLayer(); const mask = _getActiveMaskLayer(); if (!parent || !parent.ctx || !mask) throw new Error('no mask to apply'); _saveState('Apply layer mask'); _bakeMaskIntoLayer(parent.ctx, mask.canvas, 0, 0); parent.masks = parent.masks.filter((m) => m.id !== mask.id); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } },
@@ -12564,7 +12622,7 @@ function _buildEditor(container) {
'ai.removeObject': { description: 'Remove the selected object and fill naturally. {prompt}.', run: (p) => _aiInpaintSelection((p && p.prompt) || 'remove the object, fill the area naturally') }, 'ai.removeObject': { description: 'Remove the selected object and fill naturally. {prompt}.', run: (p) => _aiInpaintSelection((p && p.prompt) || 'remove the object, fill the area naturally') },
'ai.harmonize': { description: 'Harmonize the layer/selection lighting with the rest of the image; result is a NEW layer. {strength}.', run: async (p) => { const { canvas } = await _aiEditCanvas('/api/image/harmonize', { strength: Number(p && p.strength) || 0.4 }); return _placeResultLayer(canvas, 'Harmonized', state.wandMask || null); } }, 'ai.harmonize': { description: 'Harmonize the layer/selection lighting with the rest of the image; result is a NEW layer. {strength}.', run: async (p) => { const { canvas } = await _aiEditCanvas('/api/image/harmonize', { strength: Number(p && p.strength) || 0.4 }); return _placeResultLayer(canvas, 'Harmonized', state.wandMask || null); } },
'ai.generativeUpscale': { description: 'Upscale the image 2x/4x; result is a NEW layer. Falls back to a high-quality canvas upscale labelled "basic upscale" when no Real-ESRGAN model is served. {scale}.', run: async (p) => { const scale = Number(p && p.scale) === 4 ? 4 : 2; try { const { canvas } = await _aiEditCanvas('/api/image/upscale-local', { scale }); return _placeResultLayer(canvas, `Upscaled ${scale}x`, null); } catch (e) { const src = flatten(); const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(src.width * scale)); c.height = Math.max(1, Math.round(src.height * scale)); const cx = c.getContext('2d'); cx.imageSmoothingEnabled = true; cx.imageSmoothingQuality = 'high'; cx.drawImage(src, 0, 0, c.width, c.height); _saveState('Upscale (basic)'); _resizeCanvasDocument(state, c.width, c.height, { anchorX: 0, anchorY: 0 }); const layerId = _placeResultLayer(c, `Upscaled ${scale}x (basic upscale)`, null); return { layerId, scale, method: 'basic', reason: String(e && e.message || e) }; } } }, 'ai.generativeUpscale': { description: 'Upscale the image 2x/4x; result is a NEW layer. Falls back to a high-quality canvas upscale labelled "basic upscale" when no Real-ESRGAN model is served. {scale}.', run: async (p) => { const scale = Number(p && p.scale) === 4 ? 4 : 2; try { const { canvas } = await _aiEditCanvas('/api/image/upscale-local', { scale }); return _placeResultLayer(canvas, `Upscaled ${scale}x`, null); } catch (e) { const src = flatten(); const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(src.width * scale)); c.height = Math.max(1, Math.round(src.height * scale)); const cx = c.getContext('2d'); cx.imageSmoothingEnabled = true; cx.imageSmoothingQuality = 'high'; cx.drawImage(src, 0, 0, c.width, c.height); _saveState('Upscale (basic)'); _resizeCanvasDocument(state, c.width, c.height, { anchorX: 0, anchorY: 0 }); const layerId = _placeResultLayer(c, `Upscaled ${scale}x (basic upscale)`, null); return { layerId, scale, method: 'basic', reason: String(e && e.message || e) }; } } },
'ai.generateImage': { description: 'Generate an image from a prompt (current canvas as reference) and place it as a NEW layer. {prompt,width,height}.', run: async (p) => { const endpoint = (typeof window !== 'undefined' && window.geGenerateImageEndpoint) || '/api/image/generate'; const reference = flatten().toDataURL('image/png').split(',')[1]; const _pickedModel = (() => { try { const v = localStorage.getItem('ge-ai-command-model') || ''; return v.includes('::') ? v.split('::').pop() : ''; } catch (_) { return ''; } })(); const body = { prompt: (p && p.prompt) || '', width: Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)), height: Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)), reference, ...(_pickedModel ? { model: _pickedModel } : {}) }; let _gres = null; try { _gres = await fetch(endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch (_e) { throw new Error(_generationErrorMessage({ network: true, detail: (_e && _e.message) || 'network error' })); } const res = _gres; if (!res.ok) { let _gd = res.statusText; try { const e = await res.json(); _gd = e.detail || e.error || _gd; } catch (_) {} throw new Error(_generationErrorMessage({ status: res.status, detail: _gd })); } const data = await res.json(); if (data.error) throw new Error(_generationErrorMessage({ detail: data.error })); const b64 = data.image || (Array.isArray(data.images) && data.images[0]); if (!b64) throw new Error('No image returned'); try { window.__geGenerationDisabled = 0; } catch (_) {} const img = await _decodeAIImage(b64, _aiSignal); const c = document.createElement('canvas'); c.width = state.imgWidth; c.height = state.imgHeight; c.getContext('2d').drawImage(img, 0, 0, state.imgWidth, state.imgHeight); return _placeResultLayer(c, 'Generated', state.wandMask || null); } }, 'ai.generateImage': { description: 'Generate an image with the saved generation model and fit it proportionally as a new layer. {prompt,width,height}.', run: (p) => _generateImage(p || {}) },
'ai.generativeExpand': { description: 'Generatively expand the canvas to width×height; only the new border is inpainted, the original pixels are kept pixel-identical, and the seam is feathered. {width,height,anchor,prompt}.', run: async (p) => { const tw = Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)); const th = Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)); if (tw <= state.imgWidth && th <= state.imgHeight) throw new Error('target must be larger than the current canvas'); const anchor = (p && p.anchor) || 'center'; const anchorX = anchor === 'left' ? 0 : anchor === 'right' ? 1 : 0.5; const anchorY = anchor === 'top' ? 0 : anchor === 'bottom' ? 1 : 0.5; _saveState('Generative expand'); const steps = Math.max(1, Math.round(Number(p && p.steps) || 4)); const sw = state.imgWidth; const sh = state.imgHeight; _resizeCanvasDocument(state, tw, th, { anchorX, anchorY }); const flat = flatten(); const b = _contentBounds(); const mask = document.createElement('canvas'); mask.width = tw; mask.height = th; const mctx = mask.getContext('2d'); mctx.fillStyle = '#ffffff'; mctx.fillRect(0, 0, tw, th); const prefill = document.createElement('canvas'); prefill.width = tw; prefill.height = th; const pctx = prefill.getContext('2d'); pctx.imageSmoothingEnabled = true; pctx.drawImage(flat, 0, 0, tw, th); if (b) { mctx.clearRect(b.x, b.y, b.w, b.h); const bx = b.x, by = b.y, bw = b.w, bh = b.h, rx = bx + bw, by2 = by + bh; if (bx > 0) pctx.drawImage(flat, bx, by, 1, bh, 0, by, bx, bh); if (rx < tw) pctx.drawImage(flat, rx - 1, by, 1, bh, rx, by, tw - rx, bh); if (by > 0) pctx.drawImage(flat, bx, by, bw, 1, bx, 0, bw, by); if (by2 < th) pctx.drawImage(flat, bx, by2 - 1, bw, 1, bx, by2, bw, th - by2); if (bx > 0 && by > 0) pctx.drawImage(flat, bx, by, 1, 1, 0, 0, bx, by); if (rx < tw && by > 0) pctx.drawImage(flat, rx - 1, by, 1, 1, rx, 0, tw - rx, by); if (bx > 0 && by2 < th) pctx.drawImage(flat, bx, by2 - 1, 1, 1, 0, by2, bx, th - by2); if (rx < tw && by2 < th) pctx.drawImage(flat, rx - 1, by2 - 1, 1, 1, rx, by2, tw - rx, th - by2); } else { const tx = Math.round((tw - sw) * anchorX); const ty = Math.round((th - sh) * anchorY); mctx.clearRect(tx, ty, sw, sh); } const image = prefill.toDataURL('image/png').split(',')[1]; const maskB64 = mask.toDataURL('image/png').split(',')[1]; const res = await fetch('/api/image/inpaint', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image, mask: maskB64, prompt: (p && p.prompt) || 'extend the scene seamlessly, continuing the surrounding content and lighting naturally', steps }) }); if (!res.ok) { let err = res.statusText; try { const e = await res.json(); err = e.detail || e.error || err; } catch (_) {} throw new Error(err); } const data = await res.json(); if (data.error) throw new Error(data.error); if (!data.image) throw new Error('No image returned'); const img = await _decodeAIImage(data.image, _aiSignal); const seam = Math.max(12, Math.round(Math.min(tw, th) * 0.02)); let blendMask = _growMaskCanvas(mask, seam, true); blendMask = _featherMaskCanvas((cw, ch) => { const c = document.createElement('canvas'); c.width = cw; c.height = ch; return c; }, blendMask, Math.round(seam * 1.4)); const layer = createLayer('Generative expand', tw, th); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, 0, 0, tw, th); if (b) { const edge = _edgeBandAverage(flat, b); if (edge) { layer.ctx.save(); layer.ctx.globalAlpha = 0.14; layer.ctx.fillStyle = `rgb(${edge[0]}, ${edge[1]}, ${edge[2]})`; layer.ctx.fillRect(0, 0, tw, th); layer.ctx.restore(); } } _bakeMaskIntoLayer(layer.ctx, blendMask); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; composite(); _renderLayerPanel(); return { width: tw, height: th, layerId: layer.id, seam, steps }; } }, 'ai.generativeExpand': { description: 'Generatively expand the canvas to width×height; only the new border is inpainted, the original pixels are kept pixel-identical, and the seam is feathered. {width,height,anchor,prompt}.', run: async (p) => { const tw = Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)); const th = Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)); if (tw <= state.imgWidth && th <= state.imgHeight) throw new Error('target must be larger than the current canvas'); const anchor = (p && p.anchor) || 'center'; const anchorX = anchor === 'left' ? 0 : anchor === 'right' ? 1 : 0.5; const anchorY = anchor === 'top' ? 0 : anchor === 'bottom' ? 1 : 0.5; _saveState('Generative expand'); const steps = Math.max(1, Math.round(Number(p && p.steps) || 4)); const sw = state.imgWidth; const sh = state.imgHeight; _resizeCanvasDocument(state, tw, th, { anchorX, anchorY }); const flat = flatten(); const b = _contentBounds(); const mask = document.createElement('canvas'); mask.width = tw; mask.height = th; const mctx = mask.getContext('2d'); mctx.fillStyle = '#ffffff'; mctx.fillRect(0, 0, tw, th); const prefill = document.createElement('canvas'); prefill.width = tw; prefill.height = th; const pctx = prefill.getContext('2d'); pctx.imageSmoothingEnabled = true; pctx.drawImage(flat, 0, 0, tw, th); if (b) { mctx.clearRect(b.x, b.y, b.w, b.h); const bx = b.x, by = b.y, bw = b.w, bh = b.h, rx = bx + bw, by2 = by + bh; if (bx > 0) pctx.drawImage(flat, bx, by, 1, bh, 0, by, bx, bh); if (rx < tw) pctx.drawImage(flat, rx - 1, by, 1, bh, rx, by, tw - rx, bh); if (by > 0) pctx.drawImage(flat, bx, by, bw, 1, bx, 0, bw, by); if (by2 < th) pctx.drawImage(flat, bx, by2 - 1, bw, 1, bx, by2, bw, th - by2); if (bx > 0 && by > 0) pctx.drawImage(flat, bx, by, 1, 1, 0, 0, bx, by); if (rx < tw && by > 0) pctx.drawImage(flat, rx - 1, by, 1, 1, rx, 0, tw - rx, by); if (bx > 0 && by2 < th) pctx.drawImage(flat, bx, by2 - 1, 1, 1, 0, by2, bx, th - by2); if (rx < tw && by2 < th) pctx.drawImage(flat, rx - 1, by2 - 1, 1, 1, rx, by2, tw - rx, th - by2); } else { const tx = Math.round((tw - sw) * anchorX); const ty = Math.round((th - sh) * anchorY); mctx.clearRect(tx, ty, sw, sh); } const image = prefill.toDataURL('image/png').split(',')[1]; const maskB64 = mask.toDataURL('image/png').split(',')[1]; const res = await fetch('/api/image/inpaint', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image, mask: maskB64, prompt: (p && p.prompt) || 'extend the scene seamlessly, continuing the surrounding content and lighting naturally', steps }) }); if (!res.ok) { let err = res.statusText; try { const e = await res.json(); err = e.detail || e.error || err; } catch (_) {} throw new Error(err); } const data = await res.json(); if (data.error) throw new Error(data.error); if (!data.image) throw new Error('No image returned'); const img = await _decodeAIImage(data.image, _aiSignal); const seam = Math.max(12, Math.round(Math.min(tw, th) * 0.02)); let blendMask = _growMaskCanvas(mask, seam, true); blendMask = _featherMaskCanvas((cw, ch) => { const c = document.createElement('canvas'); c.width = cw; c.height = ch; return c; }, blendMask, Math.round(seam * 1.4)); const layer = createLayer('Generative expand', tw, th); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, 0, 0, tw, th); if (b) { const edge = _edgeBandAverage(flat, b); if (edge) { layer.ctx.save(); layer.ctx.globalAlpha = 0.14; layer.ctx.fillStyle = `rgb(${edge[0]}, ${edge[1]}, ${edge[2]})`; layer.ctx.fillRect(0, 0, tw, th); layer.ctx.restore(); } } _bakeMaskIntoLayer(layer.ctx, blendMask); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; composite(); _renderLayerPanel(); return { width: tw, height: th, layerId: layer.id, seam, steps }; } },
'layers.rename': { description: 'Rename a layer {id|index|name, name|newName}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); const next = String(p.name || p.newName || '').trim(); if (!next) throw new Error('name required'); _saveState('Rename layer'); l.name = next; _renderLayerPanel(); return l.id; } }, 'layers.rename': { description: 'Rename a layer {id|index|name, name|newName}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); const next = String(p.name || p.newName || '').trim(); if (!next) throw new Error('name required'); _saveState('Rename layer'); l.name = next; _renderLayerPanel(); return l.id; } },
'layers.setVisibility': { description: 'Show/hide a layer {id|index|name, visible}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState('Toggle layer visibility'); l.visible = (p.visible === undefined) ? !l.visible : !!p.visible; composite(); _renderLayerPanel(); return l.visible; } }, 'layers.setVisibility': { description: 'Show/hide a layer {id|index|name, visible}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState('Toggle layer visibility'); l.visible = (p.visible === undefined) ? !l.visible : !!p.visible; composite(); _renderLayerPanel(); return l.visible; } },
@@ -12809,6 +12867,10 @@ function _buildEditor(container) {
endpoint: window.geEditorAgentEndpoint || '/api/editor/agent', endpoint: window.geEditorAgentEndpoint || '/api/editor/agent',
getSession: () => agentBridgeSession, getSession: () => agentBridgeSession,
getModel: () => _getSelectedQuickEditModel(), getModel: () => _getSelectedQuickEditModel(),
getGenerationModel: () => {
const value = document.getElementById('ge-ai-model')?.value || '';
return value.includes('::') ? value.slice(value.indexOf('::') + 2) : '';
},
getAgentModel: () => _getSelectedQuickEditAgentModel(), getAgentModel: () => _getSelectedQuickEditAgentModel(),
}); });
} catch (e) { console.error('[ge-editor] agent init failed', e); } } catch (e) { console.error('[ge-editor] agent init failed', e); }
@@ -13908,6 +13970,12 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
return; return;
} }
_finishBlank(size.w, size.h); _finishBlank(size.w, size.h);
}).catch(error => {
console.error('[openEditor] New project failed:', error);
closeEditor({ force: true });
window.dispatchEvent(new CustomEvent('gallery-editor-setup-cancelled'));
uiModule.showError(`Could not create project: ${error?.message || 'Canvas initialization failed'}`);
throw error;
}); });
} }
@@ -14062,8 +14130,6 @@ export function closeEditor(options = {}) {
_selectionNudgeTimer = null; _selectionNudgeTimer = null;
_selectionNudgeHistorySaved = false; _selectionNudgeHistorySaved = false;
_precisionGuides = null; _precisionGuides = null;
try { _preflight?.destroy(); } catch {}
_preflight = null;
try { _aiSettings?.destroy(); } catch {} try { _aiSettings?.destroy(); } catch {}
_aiSettings = null; _aiSettings = null;
try { delete window.geEditorAiSettings; } catch (_) { window.geEditorAiSettings = null; } try { delete window.geEditorAiSettings; } catch (_) { window.geEditorAiSettings = null; }
+21
View File
@@ -10,6 +10,7 @@ export async function initAgentSettings() {
var toolsInput = el('set-agentMaxTools'); var toolsInput = el('set-agentMaxTools');
var roundsInput = el('set-agentMaxRounds'); var roundsInput = el('set-agentMaxRounds');
var supInput = el('set-agentSupervisorLadder'); var supInput = el('set-agentSupervisorLadder');
var approvalModeInput = el('set-toolApprovalMode');
var msg = el('set-agentMsg'); var msg = el('set-agentMsg');
if (!toolsInput) return; if (!toolsInput) return;
@@ -19,6 +20,21 @@ export async function initAgentSettings() {
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls; if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds; if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder; if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
if (approvalModeInput) {
approvalModeInput.value =
settings.tool_approval_mode === 'off' ? 'off' : 'ask';
}
var approvalEnvNote = el('set-toolApprovalEnvNote');
if (approvalEnvNote) {
if (settings.tool_approval_gate_env_disabled) {
approvalEnvNote.textContent =
'Forced by deployment: ODYSSEUS_TOOL_APPROVAL_GATE is set to a falsy value on this server, so the approval gate is off for every run regardless of this setting.';
approvalEnvNote.style.display = 'block';
} else {
approvalEnvNote.textContent = '';
approvalEnvNote.style.display = 'none';
}
}
} catch (e) {} } catch (e) {}
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt` // Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
@@ -37,6 +53,10 @@ export async function initAgentSettings() {
var payload = { agent_max_tool_calls: tools }; var payload = { agent_max_tool_calls: tools };
if (rounds != null) payload.agent_max_rounds = rounds; if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked; if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
if (approvalModeInput) {
payload.tool_approval_mode =
approvalModeInput.value === 'off' ? 'off' : 'ask';
}
try { try {
await _postSettings(payload); await _postSettings(payload);
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') + msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
@@ -49,6 +69,7 @@ export async function initAgentSettings() {
toolsInput.addEventListener('change', save); toolsInput.addEventListener('change', save);
if (roundsInput) roundsInput.addEventListener('change', save); if (roundsInput) roundsInput.addEventListener('change', save);
if (supInput) supInput.addEventListener('change', save); if (supInput) supInput.addEventListener('change', save);
if (approvalModeInput) approvalModeInput.addEventListener('change', save);
var cur = parseInt(toolsInput.value, 10) || 0; var cur = parseInt(toolsInput.value, 10) || 0;
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null; var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') + msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
+131 -12
View File
@@ -4,12 +4,14 @@
// them from the same places, so load order and init sequence are untouched. // them from the same places, so load order and init sequence are untouched.
import { byId as el } from './dom.js'; import { byId as el } from './dom.js';
import { postSettings as _postSettings } from './api.js'; import { saveImagePreferences as _postSettings, readImagePreferences } from './imagePreferences.js';
import { modelCaps } from '../editor/ai-models.js'; import { modelCaps } from '../editor/ai-models.js';
import { sortModelIds } from '../modelSort.js'; import { sortModelIds } from '../modelSort.js';
let preferencesListener = null;
export async function initImageSettings() { export async function initImageSettings() {
const modelSel = el('set-imgModelSelect'); const modelSel = el('set-imgModelSelect');
const editModelSel = el('set-imgEditModelSelect');
const qualSel = el('set-imgQualitySelect'); const qualSel = el('set-imgQualitySelect');
const msg = el('set-imgSettingsMsg'); const msg = el('set-imgSettingsMsg');
const enabledToggle = el('set-imgEnabledToggle'); const enabledToggle = el('set-imgEnabledToggle');
@@ -18,25 +20,41 @@ export async function initImageSettings() {
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
const endpoints = await endpointsRes.json(); const endpoints = await endpointsRes.json();
const imageModels = new Set(); const imageModels = new Set();
const editModels = new Set();
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => { (Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
if (!endpoint.is_enabled || !endpoint.online) return; if (!endpoint.is_enabled || !endpoint.online) return;
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => { (Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
if (modelId && modelCaps(modelId, '', endpoint.model_type).gen) imageModels.add(String(modelId)); if (modelId && modelCaps(modelId, '', endpoint.model_type).gen) imageModels.add(String(modelId));
if (modelId && modelCaps(modelId, '', endpoint.model_type).inpaint) editModels.add(String(modelId));
}); });
}); });
modelSel.length = 1;
sortModelIds(Array.from(imageModels)).forEach(mid => { sortModelIds(Array.from(imageModels)).forEach(mid => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = mid; opt.value = mid;
opt.textContent = mid; opt.textContent = mid;
modelSel.appendChild(opt); modelSel.appendChild(opt);
}); });
if (editModelSel) {
editModelSel.length = 1;
for (const mid of sortModelIds(Array.from(editModels))) {
const option = document.createElement('option');
option.value = mid;
option.textContent = mid;
editModelSel.appendChild(option);
}
}
} catch (e) { console.warn('Failed to load models for image settings', e); } } catch (e) { console.warn('Failed to load models for image settings', e); }
let currentEnabled = enabledToggle ? enabledToggle.checked : false;
try { try {
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const settings = await readImagePreferences();
const settings = await settingsRes.json(); if (modelSel && 'image_model' in settings) modelSel.value = settings.image_model || '';
if (settings.image_model) modelSel.value = settings.image_model; if (editModelSel && 'image_edit_model' in settings) editModelSel.value = settings.image_edit_model || '';
if (settings.image_quality) qualSel.value = settings.image_quality; if (qualSel && 'image_quality' in settings) qualSel.value = settings.image_quality || 'medium';
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true; if (enabledToggle) {
enabledToggle.checked = settings.image_gen_enabled === true;
currentEnabled = enabledToggle.checked;
}
} catch (e) { console.warn('Failed to load settings', e); } } catch (e) { console.warn('Failed to load settings', e); }
function syncImgDisabled() { function syncImgDisabled() {
@@ -47,14 +65,115 @@ export async function initImageSettings() {
} }
syncImgDisabled(); syncImgDisabled();
async function saveSettings() { function checkIsAdmin() {
return typeof window !== 'undefined' && !!window._isAdmin;
}
function applyAdminToggleState() {
if (!enabledToggle) return;
const admin = checkIsAdmin();
if (!admin) {
enabledToggle.disabled = true;
const adminTitle = 'Admin access is required to change image generation availability.';
enabledToggle.title = adminTitle;
if (enabledToggle.parentElement) enabledToggle.parentElement.title = adminTitle;
} else {
enabledToggle.disabled = false;
const adminTitle = 'Enable or disable image generation globally';
enabledToggle.title = adminTitle;
if (enabledToggle.parentElement) enabledToggle.parentElement.title = adminTitle;
}
}
applyAdminToggleState();
async function saveModelPreferences() {
try { try {
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value }); const res = await _postSettings({
image_model: modelSel ? modelSel.value : '',
image_edit_model: editModelSel?.value || '',
image_quality: qualSel ? qualSel.value : 'medium',
});
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`)); if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000); msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; } } catch (e) {
if (e.globalSaved) {
msg.textContent = 'Policy saved, but failed to clear preferences: ' + (e.message || 'cleanup error');
msg.style.color = 'var(--red)';
} else {
msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save';
msg.style.color = 'var(--red)';
} }
modelSel.addEventListener('change', saveSettings); }
qualSel.addEventListener('change', saveSettings); }
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
async function saveToggle() {
if (!checkIsAdmin()) {
if (enabledToggle) enabledToggle.checked = currentEnabled;
syncImgDisabled();
msg.textContent = 'Admin access is required to change these settings.';
msg.style.color = 'var(--red)';
return;
}
const targetState = enabledToggle ? enabledToggle.checked : false;
try {
const res = await _postSettings({ image_gen_enabled: targetState });
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
if (enabledToggle) currentEnabled = enabledToggle.checked;
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) {
if (e.globalSaved) {
currentEnabled = targetState;
try {
const authSettings = await readImagePreferences();
if (typeof authSettings.image_gen_enabled === 'boolean') {
currentEnabled = authSettings.image_gen_enabled;
}
} catch (_) {}
if (enabledToggle) enabledToggle.checked = currentEnabled;
syncImgDisabled();
msg.textContent = 'Policy saved, but failed to clear preferences: ' + (e.message || 'cleanup error');
msg.style.color = 'var(--red)';
} else {
try {
const authSettings = await readImagePreferences();
if (typeof authSettings.image_gen_enabled === 'boolean') {
currentEnabled = authSettings.image_gen_enabled;
}
} catch (_) {}
if (enabledToggle) enabledToggle.checked = currentEnabled;
syncImgDisabled();
msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save';
msg.style.color = 'var(--red)';
}
}
}
if (modelSel) modelSel.addEventListener('change', saveModelPreferences);
if (editModelSel) editModelSel.addEventListener('change', saveModelPreferences);
if (qualSel) qualSel.addEventListener('change', saveModelPreferences);
if (enabledToggle) {
enabledToggle.addEventListener('change', async function() {
if (!checkIsAdmin()) {
enabledToggle.checked = currentEnabled;
syncImgDisabled();
msg.textContent = 'Admin access is required to change these settings.';
msg.style.color = 'var(--red)';
return;
}
syncImgDisabled();
await saveToggle();
});
}
if (preferencesListener) window.removeEventListener('image-preferences-changed', preferencesListener);
preferencesListener = event => {
if ('image_model' in event.detail && modelSel) modelSel.value = event.detail.image_model || '';
if (editModelSel && 'image_edit_model' in event.detail) editModelSel.value = event.detail.image_edit_model || '';
if ('image_gen_enabled' in event.detail && enabledToggle) {
enabledToggle.checked = event.detail.image_gen_enabled === true;
currentEnabled = enabledToggle.checked;
syncImgDisabled();
}
};
window.addEventListener('image-preferences-changed', preferencesListener);
} }
+107
View File
@@ -0,0 +1,107 @@
import { invalidateSettings } from '../appConfig.js';
const USER_PREFERENCE_KEYS = ['image_model', 'image_edit_model', 'image_quality'];
export async function readImagePreferences() {
const [prefsRes, settingsRes] = await Promise.all([
fetch('/api/prefs', { credentials: 'same-origin' }).catch(() => null),
fetch('/api/auth/settings', { credentials: 'same-origin' }).catch(() => null),
]);
const prefs = (prefsRes && prefsRes.ok) ? await prefsRes.json().catch(() => ({})) : {};
const settings = (settingsRes && settingsRes.ok) ? await settingsRes.json().catch(() => ({})) : {};
if (!prefsRes?.ok && !settingsRes?.ok) throw new Error('Could not load image settings');
return {
...settings,
image_model: prefs?.image_model || settings?.image_model || '',
image_edit_model: prefs?.image_edit_model || settings?.image_edit_model || '',
image_quality: prefs?.image_quality || settings?.image_quality || 'medium',
};
}
let saveQueue = Promise.resolve();
export function waitForImagePreferences() { return saveQueue; }
export function saveImagePreferences(patch) {
const save = async () => {
let response;
try {
response = await fetch('/api/auth/settings', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),
});
} catch (netErr) {
invalidateSettings();
netErr.globalSaved = false;
throw netErr;
}
invalidateSettings();
if (response.status === 403) {
const userEntries = Object.entries(patch).filter(([k]) => USER_PREFERENCE_KEYS.includes(k));
if (userEntries.length === 0) {
const err = new Error('Admin access is required to change these settings.');
err.status = 403;
err.globalSaved = false;
throw err;
}
for (const [key, value] of userEntries) {
const prefRes = await fetch(`/api/prefs/${encodeURIComponent(key)}`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
if (!prefRes.ok) {
const err = new Error(await prefRes.text().catch(() => 'Could not save image preferences'));
err.status = prefRes.status;
err.globalSaved = false;
throw err;
}
}
response = typeof Response !== 'undefined'
? new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } })
: { ok: true, status: 200, json: async () => ({ ok: true }), text: async () => '{"ok":true}' };
} else if (response.ok) {
const clearResults = await Promise.all(USER_PREFERENCE_KEYS.map(async key => {
try {
const res = await fetch(`/api/prefs/${encodeURIComponent(key)}`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: null }),
});
if (!res.ok) {
const err = new Error(await res.text().catch(() => `HTTP ${res.status}`));
err.status = res.status;
return { ok: false, error: err };
}
return { ok: true };
} catch (err) {
return { ok: false, error: err };
}
}));
const failure = clearResults.find(r => !r.ok);
if (failure) {
invalidateSettings();
if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') {
window.dispatchEvent(new CustomEvent('image-preferences-changed', { detail: patch }));
}
const err = new Error(failure.error?.message || 'Could not clear personal preferences');
err.status = failure.error?.status || 500;
err.cause = failure.error;
err.globalSaved = true;
err.phase = 'cleanup';
throw err;
}
} else {
const err = new Error('Could not save image settings');
err.status = response.status;
err.globalSaved = false;
throw err;
}
invalidateSettings();
if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') {
window.dispatchEvent(new CustomEvent('image-preferences-changed', { detail: patch }));
}
return response;
};
const result = saveQueue.then(save);
saveQueue = result.catch(() => {});
return result;
}
+27 -23
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh. // - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached. // - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes. // Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v1115-pr40-editor-precache'; const CACHE_NAME = 'odysseus-v1135-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them // KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting. // gives offline math fallback glyphs instead of proper typesetting.
@@ -40,28 +40,28 @@ const KATEX_FONTS = [
const PRECACHE = [ const PRECACHE = [
'/static/js/compare/sse.js', '/static/js/compare/sse.js',
'/', '/',
'/static/css/00-tokens.css?v=20260929csssplit', '/static/css/00-tokens.css?v=20261007detailscroll1',
'/static/css/01-agent-chat.css?v=20260929csssplit', '/static/css/01-agent-chat.css?v=20261007detailscroll1',
'/static/css/02-compare.css?v=20260929csssplit', '/static/css/02-compare.css?v=20261007detailscroll1',
'/static/css/03-agent-chat.css?v=20260929csssplit', '/static/css/03-agent-chat.css?v=20261007detailscroll1',
'/static/css/04-memory.css?v=20260929csssplit', '/static/css/04-memory.css?v=20261007detailscroll1',
'/static/css/05-documents.css?v=20260929csssplit', '/static/css/05-documents.css?v=20261007detailscroll1',
'/static/css/06-admin-settings.css?v=20260929csssplit', '/static/css/06-admin-settings.css?v=20261007detailscroll1',
'/static/css/07-documents.css?v=20260929csssplit', '/static/css/07-documents.css?v=20261007detailscroll1',
'/static/css/08-skills.css?v=20260929csssplit', '/static/css/08-skills.css?v=20261007detailscroll1',
'/static/css/09-gallery.css?v=20260929csssplit', '/static/css/09-gallery.css?v=20261007detailscroll1',
'/static/css/10-cookbook.css?v=20260929csssplit', '/static/css/10-cookbook.css?v=20261007detailscroll1',
'/static/css/11-tasks.css?v=20260929csssplit', '/static/css/11-tasks.css?v=20261007detailscroll1',
'/static/css/12-gallery.css?v=20260929csssplit', '/static/css/12-gallery.css?v=20261007detailscroll1',
'/static/css/13-image-editor.css?v=20260929csssplit', '/static/css/13-image-editor.css?v=20261007detailscroll1',
'/static/css/14-email.css?v=20260929csssplit', '/static/css/14-email.css?v=20261007detailscroll1',
'/static/css/15-notes.css?v=20260929csssplit', '/static/css/15-notes.css?v=20261007detailscroll1',
'/static/css/16-calendar.css?v=20260929csssplit', '/static/css/16-calendar.css?v=20261007detailscroll1',
'/static/css/17-research.css?v=20260929csssplit', '/static/css/17-research.css?v=20261007detailscroll1',
'/static/css/documents-gallery-editor.css?v=20260929csssplit', '/static/css/documents-gallery-editor.css?v=20261007detailscroll1',
'/static/css/email-calendar-notes-tasks.css?v=20260929csssplit', '/static/css/email-calendar-notes-tasks.css?v=20261007detailscroll1',
'/static/css/cookbook-research-memory-settings.css?v=20260929csssplit', '/static/css/cookbook-research-memory-settings.css?v=20261007detailscroll1',
'/static/app.js?v=20260929csssplit', '/static/app.js?v=20261007detailscroll1',
'/static/js/storage.js', '/static/js/storage.js',
'/static/js/appConfig.js', '/static/js/appConfig.js',
'/static/js/ui.js?v=20260916largetoolscroll1', '/static/js/ui.js?v=20260916largetoolscroll1',
@@ -237,6 +237,10 @@ const PANEL_PRECACHE = [
'/static/js/editor/aspect-fit.js', '/static/js/editor/aspect-fit.js',
'/static/js/editor/preflight.js', '/static/js/editor/preflight.js',
'/static/js/editor/ai-settings.js', '/static/js/editor/ai-settings.js',
'/static/js/editor/image-generation.js',
'/static/js/editor/image-result-geometry.js',
'/static/js/editor/quick-edit-job.js',
'/static/js/settings/imagePreferences.js',
'/static/js/editor/look-recipes.js', '/static/js/editor/look-recipes.js',
'/static/js/editor/editor-control.js', '/static/js/editor/editor-control.js',
'/static/js/editor/editor-bridge.js', '/static/js/editor/editor-bridge.js',
@@ -24,6 +24,7 @@ const REAL_MODULES = new Set([
path.join(JS, 'settings/speech.js'), path.join(JS, 'settings/speech.js'),
path.join(JS, 'settings/writingStyle.js'), path.join(JS, 'settings/writingStyle.js'),
path.join(JS, 'settings/imageModels.js'), path.join(JS, 'settings/imageModels.js'),
path.join(JS, 'settings/imagePreferences.js'),
path.join(JS, 'settings/agent.js'), path.join(JS, 'settings/agent.js'),
path.join(JS, 'settings/shell.js'), path.join(JS, 'settings/shell.js'),
path.join(JS, 'settings/peek.js'), path.join(JS, 'settings/peek.js'),
+1 -1
View File
@@ -10,7 +10,7 @@ import unittest
path = Path(__file__).resolve().parents[1] / "routes/chat_routes.py" path = Path(__file__).resolve().parents[1] / "routes/chat_routes.py"
tree = ast.parse(path.read_text()) tree = ast.parse(path.read_text(encoding="utf-8"))
node = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "_AgentRenderState") node = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "_AgentRenderState")
namespace = {} namespace = {}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace) exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace)
@@ -0,0 +1,71 @@
"""An unknown context window must not be budgeted like a tiny local server (#6678).
``budget_context_for_model`` returns 0 when the endpoint does not prove its
window, and ``compute_input_token_budget`` answers 0 with the conservative
default budget. The agent loop's small-server clamp (``<= 8192``) also caught
0 and cut the request to 1200 tokens, so an open document or earlier context
of a few thousand tokens was trimmed away before the model saw it.
"""
import inspect
import json
import pytest
DONE = 'data: [DONE]\n\n'
MARKER = 'NIGHTJAR-7'
# ~3000 estimated tokens: over the old 1200 clamp, under the 6000 default.
CONTEXT = ''.join(
f'## Project-{n}\nOwner: team {n}. Status: active. Notes: routine maintenance '
'and quarterly review scheduled for every workstream in this registry.\n\n'
for n in range(60)
) + f'## Project-Alpha\nAlias: {MARKER}\n'
def _event(payload):
return 'data: ' + json.dumps(payload) + '\n\n'
async def _first_request(monkeypatch, tmp_path, context_window):
import src.agent_loop as al
from tests.test_agent_runtime_context import _patch_fake_skills
_patch_fake_skills(monkeypatch)
monkeypatch.setattr('src.tool_index.get_tool_index', lambda: None)
monkeypatch.setattr('src.model_context.budget_context_for_model',
lambda *args, **kwargs: context_window)
seen = []
async def provider(candidates, messages, **kwargs):
# Like llm_core: the per-candidate factory shapes and trims the request.
url, model, headers = candidates[0][:3]
request = kwargs['candidate_request_factory'](0, url, model, headers)
if inspect.isawaitable(request):
request = await request
seen.append((request or {}).get('messages', messages))
yield _event({'delta': 'ok'})
yield DONE
monkeypatch.setattr(al, 'stream_llm_with_fallback', provider)
history = [
{'role': 'user', 'content': 'Keep this registry for later:\n\n' + CONTEXT},
{'role': 'assistant', 'content': 'Saved.'},
{'role': 'user', 'content': 'What is the alias of Project-Alpha?'},
]
async for _ in al.stream_agent_loop('http://127.0.0.1:9/v1', 'model', history,
max_rounds=1, workspace=str(tmp_path)):
pass
assert seen, 'the provider was never called'
return json.dumps(seen[0])
@pytest.mark.asyncio
async def test_unknown_context_window_keeps_earlier_context(monkeypatch, tmp_path):
request = await _first_request(monkeypatch, tmp_path, 0)
assert MARKER in request
@pytest.mark.asyncio
async def test_known_small_window_is_still_clamped(monkeypatch, tmp_path):
# The clamp exists for small local servers whose window is known (n_ctx
# 2048 here); it must keep protecting them.
request = await _first_request(monkeypatch, tmp_path, 2048)
assert MARKER not in request
File diff suppressed because it is too large Load Diff
@@ -3,8 +3,8 @@ from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
CHAT = (ROOT / "static/js/chat.js").read_text() CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
SESSIONS = (ROOT / "static/js/sessions.js").read_text() SESSIONS = (ROOT / "static/js/sessions.js").read_text(encoding="utf-8")
CSS = app_css() CSS = app_css()
+100
View File
@@ -0,0 +1,100 @@
"""Effective RAG policy reaches the real search and context consumer."""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from src.chat_processor import ChatProcessor
from tests.test_kv_cache_invalidation_2927 import _build_context_harness
pytestmark = pytest.mark.area_routes
@pytest.mark.parametrize('mode', ['chat', 'agent', 'compare'])
@pytest.mark.parametrize('policy', [
'normal', 'incognito', 'preprocessing_disabled', 'research_spinoff', 'low_signal',
])
@pytest.mark.parametrize('use_rag', [None, 'true', 'false', True, False])
async def test_rag_policy_preserves_context_channels(monkeypatch, mode, policy, use_rag):
"""Exercise effective policy with real preface assembly and synthetic I/O.
Nonempty results prove permitted retrieval still injects context. Independent
memory, skills and web spies ensure a RAG disable cannot disable other channels.
"""
from routes import chat_helpers
from src import chat_processor, llm_core, user_time
sess, request, handler, _ = _build_context_harness(monkeypatch, chat_helpers, history=[])
monkeypatch.setattr(chat_helpers, 'load_prefs_for_user', lambda user: {
'memory_enabled': True, 'skills_enabled': True})
monkeypatch.setattr(user_time, 'current_datetime_context_message', lambda: {
'role': 'user', 'content': 'Synthetic current date context'})
incognito = policy == 'incognito'
preprocessing = policy != 'preprocessing_disabled'
if policy == 'research_spinoff':
sess.history = [{'role': 'system', 'metadata': {'research_spinoff_from': 'fixture-report'}}]
message = 'hi' if policy == 'low_signal' else 'Find the retention policy for archived project documents'
search = Mock(return_value=[{
'document': 'Synthetic retention document',
'metadata': {'filename': 'fixture.txt'}, 'similarity': 0.99,
}])
memory = Mock(return_value=[{
'id': 'fixture-memory', 'text': 'User identity synthetic memory',
'category': 'identity', 'pinned': True,
}])
skills = Mock(return_value=[{'name': 'fixture_skill', 'description': 'Synthetic skill'}])
web = Mock(return_value=('Synthetic web context', [{'url': 'https://example.invalid/fixture'}]))
query = Mock(return_value='retention policy')
monkeypatch.setattr(llm_core, 'llm_call', query)
monkeypatch.setattr(chat_processor, 'comprehensive_web_search', web)
processor = ChatProcessor(
memory_manager=SimpleNamespace(load=memory),
personal_docs_manager=SimpleNamespace(rag_manager=SimpleNamespace(search=search)),
skills_manager=SimpleNamespace(index_for=skills),
)
received = []
real_preface = processor.build_context_preface
def capture(**kwargs):
received.append(dict(kwargs))
return real_preface(**kwargs)
monkeypatch.setattr(processor, 'build_context_preface', capture)
ctx = await chat_helpers.build_chat_context(
sess=sess, request=request, chat_handler=handler, chat_processor=processor,
message=message, session_id='c5-policy', use_rag=use_rag,
incognito=incognito, allow_tool_preprocessing=preprocessing,
persist_user_message=False, agent_mode=mode == 'agent',
compare_mode=mode == 'compare', use_web='true',
search_context='Synthetic prefetched context' if mode == 'compare' else None,
)
rag_allowed = policy == 'normal' and str(use_rag).lower() != 'false'
assert len(received) == 1
assert search.call_count == int(rag_allowed)
assert received[0]['use_rag'] is rag_allowed
if rag_allowed:
search.assert_called_once_with(message, k=5, owner='tester')
assert ctx.rag_sources == [{
'filename': 'fixture.txt', 'snippet': 'Synthetic retention document', 'similarity': 0.99,
}]
else:
assert ctx.rag_sources == []
text = '\n'.join(m.get('content', '') for m in ctx.messages)
assert ('Synthetic retention document' in text) is rag_allowed
memory_allowed = policy == 'normal'
assert received[0]['use_memory'] is memory_allowed
assert memory.call_count == int(memory_allowed)
assert ('User identity synthetic memory' in text) is memory_allowed
assert bool(ctx.used_memories) is memory_allowed
skills_allowed = policy in {'normal', 'research_spinoff'}
assert received[0]['use_skills'] is skills_allowed
assert skills.call_count == int(skills_allowed and mode == 'agent')
assert ('fixture_skill' in text) is (skills_allowed and mode == 'agent')
web_allowed = preprocessing and policy != 'low_signal' and mode != 'compare'
assert received[0]['use_web'] is web_allowed
assert web.call_count == query.call_count == int(web_allowed)
assert ('Synthetic web context' in text) is web_allowed
assert bool(ctx.web_sources) is web_allowed
assert ('Synthetic prefetched context' in text) is (
mode == 'compare' and preprocessing and policy != 'low_signal')
+2 -2
View File
@@ -2,8 +2,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
CHAT = (ROOT / "static/js/chat.js").read_text() CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text() RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
def test_processing_indicator_shows_live_ttft_timer(): def test_processing_indicator_shows_live_ttft_timer():
+28
View File
@@ -0,0 +1,28 @@
"""Every variable a shipped compose file forwards must carry a default.
A bare ``${VAR}`` makes ``docker compose`` print "variable is not set" on every
command for a default ``.env`` (#6619), which greets fresh installs with a
warning that looks like a misconfiguration.
"""
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
COMPOSE_FILES = sorted(ROOT.glob("docker-compose*.yml")) + sorted((ROOT / "docker").glob("*.yml"))
# ${VAR} with no ``:-``/``-``/``:?``/``?`` modifier.
BARE_VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.relative_to(ROOT).as_posix())
def test_compose_variables_have_defaults(path):
bare = [
f"{path.name}:{lineno}: ${{{name}}}"
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
if not line.lstrip().startswith("#")
for name in BARE_VAR.findall(line)
]
assert not bare, "compose variables without a default:\n" + "\n".join(bare)
@@ -1,5 +1,9 @@
from pathlib import Path from pathlib import Path
import pytest
from tests.helpers.database import disposable_database
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
COOKBOOK_RUNNING = ROOT / "static" / "js" / "cookbookRunning.js" COOKBOOK_RUNNING = ROOT / "static" / "js" / "cookbookRunning.js"
@@ -28,3 +32,496 @@ def test_cookbook_advertised_bind_urls_keep_connectable_host():
assert "function _endpointFromAdvertisedUrl" in src assert "function _endpointFromAdvertisedUrl" in src
assert "_isAnyBindHost(u.hostname) ? currentHost" in src assert "_isAnyBindHost(u.hostname) ? currentHost" in src
assert "host = u.hostname || host;" not in src assert "host = u.hostname || host;" not in src
@pytest.fixture
def endpoint_db(tmp_path, monkeypatch):
import core.database as database
import routes.model_routes as model_routes
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(database, "SessionLocal", factory)
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *args, **kwargs: None)
yield factory
def _launch_llm(
repo_id: str,
port: int,
serve_session_id: str | None = None,
) -> str:
import routes.cookbook_routes as cookbook_routes
from routes.cookbook_helpers import ServeRequest
router = cookbook_routes.setup_cookbook_routes()
serve = next(
route.endpoint for route in router.routes
if route.path == "/api/model/serve" and "POST" in route.methods
)
cells = dict(zip(serve.__code__.co_freevars, serve.__closure__ or ()))
register = cells["_auto_register_llm_endpoint"].cell_contents
endpoint_id = register(
ServeRequest(repo_id=repo_id, cmd=f"vllm serve {repo_id} --port {port}"),
None,
serve_session_id=serve_session_id,
)
assert endpoint_id
return endpoint_id
def _endpoint(factory, endpoint_id: str):
import core.database as database
with factory() as db:
return db.get(database.ModelEndpoint, endpoint_id)
@pytest.mark.parametrize("reuse", [False, True])
def test_launch_keeps_endpoint_of_other_model_with_same_short_name(endpoint_db, reuse):
if reuse:
mirror = _launch_llm("unsloth/Llama-3.1-8B-Instruct", 8002)
original = _launch_llm("meta-llama/Llama-3.1-8B-Instruct", 8001)
launched = _launch_llm("unsloth/Llama-3.1-8B-Instruct", 8002)
if reuse:
assert launched == mirror
assert _endpoint(endpoint_db, original) is not None
assert _endpoint(endpoint_db, launched) is not None
@pytest.mark.parametrize("reuse", [False, True])
def test_relaunch_on_another_port_preserves_existing_endpoint(endpoint_db, reuse):
if reuse:
current = _launch_llm("meta-llama/Llama-3.1-8B-Instruct", 8002)
stale = _launch_llm("Qwen/Qwen3-8B", 8001)
launched = _launch_llm("Qwen/Qwen3-8B", 8002)
if reuse:
assert launched == current
assert _endpoint(endpoint_db, stale) is not None
assert _endpoint(endpoint_db, launched) is not None
def test_sweep_ignores_endpoint_now_serving_a_different_model(endpoint_db):
endpoint = _launch_llm("Qwen/Qwen3-8B", 8001)
_launch_llm("meta-llama/Llama-3.1-8B", 8001)
_launch_llm("Qwen/Qwen3-8B", 8001)
_launch_llm("meta-llama/Llama-3.1-8B", 8002)
assert _endpoint(endpoint_db, endpoint) is not None
def test_sweep_ignores_endpoints_not_created_by_cookbook(endpoint_db):
import core.database as database
with endpoint_db() as db:
db.add(database.ModelEndpoint(
id="manual", name="Qwen3-8B", base_url="http://localhost:9000/v1",
pinned_models='["Qwen/Qwen3-8B"]',
))
db.commit()
_launch_llm("Qwen/Qwen3-8B", 8002)
assert _endpoint(endpoint_db, "manual") is not None
def test_manual_pin_order_does_not_change_on_relaunch(endpoint_db):
import json
from core.database import ModelEndpoint
endpoint_id = _launch_llm("organization-a/Model-A", 8001)
with endpoint_db() as db:
endpoint = db.get(ModelEndpoint, endpoint_id)
endpoint.pinned_models = json.dumps([
"organization-a/Model-A",
"organization-b/Model-B",
])
db.commit()
_launch_llm("organization-a/Model-A", 8001)
with endpoint_db() as db:
endpoint = db.get(ModelEndpoint, endpoint_id)
assert json.loads(endpoint.pinned_models) == [
"organization-a/Model-A",
"organization-b/Model-B",
]
def test_manual_pins_cannot_trigger_endpoint_deletion(endpoint_db):
import json
from core.database import ModelEndpoint
original = _launch_llm("organization-a/Model-A", 8001)
with endpoint_db() as db:
endpoint = db.get(ModelEndpoint, original)
endpoint.pinned_models = json.dumps([
"organization-a/Model-A",
"organization-b/Model-B",
])
db.commit()
_launch_llm("organization-b/Model-B", 8002)
assert _endpoint(endpoint_db, original) is not None
def test_unavailable_model_probe_never_deletes_another_endpoint(
endpoint_db, monkeypatch
):
import routes.model_routes as model_routes
original = _launch_llm("organization-a/Model-A", 8001)
monkeypatch.setattr(
model_routes,
"_probe_endpoint",
lambda *args, **kwargs: [],
)
_launch_llm("organization-a/Model-A", 8002)
assert _endpoint(endpoint_db, original) is not None
@pytest.mark.asyncio
async def test_old_watchdog_cannot_delete_reused_endpoint(
endpoint_db,
monkeypatch,
endpoint_http_api,
):
import asyncio
import urllib.request
import routes.cookbook_routes as cookbook_routes
original = _launch_llm(
"organization-a/Model-A",
8001,
serve_session_id="serve-old",
)
reused = _launch_llm(
"organization-b/Model-B",
8001,
serve_session_id="serve-new",
)
assert original == reused
with endpoint_db() as db:
from core.database import ModelEndpoint
endpoint = db.get(ModelEndpoint, reused)
assert endpoint.cookbook_serve_session_id == "serve-new"
router = cookbook_routes.setup_cookbook_routes()
serve = next(
route.endpoint
for route in router.routes
if route.path == "/api/model/serve"
and "POST" in route.methods
)
cells = dict(
zip(
serve.__code__.co_freevars,
serve.__closure__ or (),
)
)
watchdog = cells["_serve_crash_watchdog"].cell_contents
class FakeProcess:
async def communicate(self):
return (
b"=== Process exited with code 1 ===\n",
b"",
)
async def fake_subprocess(*args, **kwargs):
return FakeProcess()
async def fake_sleep(seconds):
return None
def unavailable(*args, **kwargs):
raise OSError("Server still warming up")
monkeypatch.setattr(
asyncio,
"create_subprocess_exec",
fake_subprocess,
)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
monkeypatch.setattr(
urllib.request,
"urlopen",
unavailable,
)
await watchdog(
endpoint_id=original,
session_id="serve-old",
remote=None,
ssh_port=None,
is_windows=False,
)
assert _endpoint(endpoint_db, reused) is not None
# The current serve's own crash watchdog must still be able
# to remove its endpoint when it actually fails.
await watchdog(
endpoint_id=reused,
session_id="serve-new",
remote=None,
ssh_port=None,
is_windows=False,
)
assert _endpoint(endpoint_db, reused) is None
def test_cookbook_serve_session_migration_is_idempotent(
tmp_path,
monkeypatch,
):
from sqlalchemy import create_engine, inspect, text
import core.database as database
engine = create_engine(
f"sqlite:///{tmp_path / 'legacy-endpoints.db'}"
)
try:
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE model_endpoints "
"(id VARCHAR PRIMARY KEY)"
)
)
monkeypatch.setattr(database, "engine", engine)
database._migrate_add_cookbook_serve_session_id_column()
database._migrate_add_cookbook_serve_session_id_column()
columns = {
column["name"]
for column in inspect(engine).get_columns(
"model_endpoints"
)
}
assert "cookbook_serve_session_id" in columns
finally:
engine.dispose()
@pytest.fixture
def endpoint_http_api(endpoint_db, monkeypatch):
"""Route deletion through a disposable in-process FastAPI app."""
import httpx
from fastapi import FastAPI
import routes.model_routes as model_routes
import routes.prefs_routes as prefs_routes
import core.constants as constants
import src.cookbook_serve_lifecycle as lifecycle
monkeypatch.setattr(model_routes, "SessionLocal", endpoint_db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
settings = {}
preferences = {}
monkeypatch.setattr(model_routes, "_load_settings", lambda: settings)
monkeypatch.setattr(model_routes, "_save_settings", lambda value: settings.update(value))
monkeypatch.setattr(prefs_routes, "_load", lambda: preferences)
monkeypatch.setattr(prefs_routes, "_save", lambda value: preferences.update(value))
# Endpoint deletion must not touch the user's running sessions.
import src.ai_interaction as ai_interaction
monkeypatch.setattr(ai_interaction, "get_session_manager", lambda: None)
app = FastAPI()
router = model_routes.setup_model_routes(None)
app.include_router(router)
original_client = httpx.AsyncClient
transport = httpx.ASGITransport(app=app)
def client_factory(*args, **kwargs):
kwargs["transport"] = transport
return original_client(*args, **kwargs)
monkeypatch.setattr(httpx, "AsyncClient", client_factory)
monkeypatch.setattr(constants, "internal_api_base", lambda: "http://testserver")
monkeypatch.setattr(lifecycle, "internal_api_base", lambda: "http://testserver")
return {"settings": settings, "preferences": preferences, "router": router}
@pytest.mark.asyncio
async def test_ownership_condition_preserves_settings_on_mismatch(
endpoint_db, endpoint_http_api,
):
import httpx
original = _launch_llm("organization-a/Model-A", 8001, "serve-old")
reused = _launch_llm("organization-b/Model-B", 8001, "serve-new")
assert reused == original
settings = endpoint_http_api["settings"]
settings.update({"default_endpoint_id": reused, "default_model": "organization-b/Model-B"})
async with httpx.AsyncClient() as client:
response = await client.delete(
f"http://testserver/api/model-endpoints/{reused}",
params={"expected_cookbook_serve_session_id": "serve-old"},
)
assert response.status_code == 409
assert _endpoint(endpoint_db, reused) is not None
assert settings["default_endpoint_id"] == reused
@pytest.mark.asyncio
async def test_owned_deletion_clears_settings_and_model_cache(
endpoint_db, endpoint_http_api,
):
import httpx
from core.database import Session as DbSession
endpoint_id = _launch_llm("organization-a/Model-A", 8001, "serve-current")
settings = endpoint_http_api["settings"]
settings.update({
"default_endpoint_id": endpoint_id,
"default_model": "organization-a/Model-A",
"foreground_model_fallbacks": [{"endpoint_id": endpoint_id, "model": "x"}],
})
with endpoint_db() as db:
db.add(DbSession(
id="owned-delete-session", name="Test", owner=None,
endpoint_url="http://localhost:8001/v1",
endpoint_id=endpoint_id, model="organization-a/Model-A",
headers={"Authorization": "Bearer test-only"},
))
db.commit()
# Seed the actual model listing cache captured by the route closure.
router = endpoint_http_api["router"]
route = next(r for r in router.routes if r.path == "/api/model-endpoints/{ep_id}" and "DELETE" in r.methods)
closure = dict(zip(route.endpoint.__code__.co_freevars, route.endpoint.__closure__ or ()))
invalidate = closure["_invalidate_models_cache"].cell_contents
cache_closure = dict(zip(invalidate.__code__.co_freevars, invalidate.__closure__ or ()))
models_cache = cache_closure["_models_cache"].cell_contents
models_cache["test"] = {"data": [], "time": 0}
async with httpx.AsyncClient() as client:
response = await client.delete(
f"http://testserver/api/model-endpoints/{endpoint_id}",
params={"expected_cookbook_serve_session_id": "serve-current"},
)
assert response.status_code == 200, response.text
assert _endpoint(endpoint_db, endpoint_id) is None
assert settings["default_endpoint_id"] == ""
assert settings["foreground_model_fallbacks"] == []
assert not models_cache
with endpoint_db() as db:
sess = db.get(DbSession, "owned-delete-session")
assert sess.headers == {}
@pytest.mark.asyncio
async def test_ordinary_admin_delete_does_not_require_serve_owner(
endpoint_db, endpoint_http_api,
):
import httpx
endpoint_id = _launch_llm("organization-a/Model-A", 8001, "serve-new")
async with httpx.AsyncClient() as client:
response = await client.delete(f"http://testserver/api/model-endpoints/{endpoint_id}")
assert response.status_code == 200, response.text
assert _endpoint(endpoint_db, endpoint_id) is None
def _launch_image(repo_id: str, port: int, monkeypatch) -> str:
import routes.cookbook_routes as cookbook_routes
import src.settings as settings
from routes.cookbook_helpers import ServeRequest
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": repo_id})
monkeypatch.setattr(settings, "save_settings", lambda _settings: None)
router = cookbook_routes.setup_cookbook_routes()
serve = next(
route.endpoint for route in router.routes
if route.path == "/api/model/serve" and "POST" in route.methods
)
cells = dict(zip(serve.__code__.co_freevars, serve.__closure__ or ()))
register = cells["_auto_register_image_endpoint"].cell_contents
endpoint_id = register(
ServeRequest(repo_id=repo_id, cmd=f"python diffusion_server.py --model {repo_id} --port {port}"), None
)
assert endpoint_id
return endpoint_id
def _rename(factory, endpoint_id: str, name: str) -> None:
import core.database as database
with factory() as db:
db.get(database.ModelEndpoint, endpoint_id).name = name
db.commit()
@pytest.mark.parametrize("relaunched", ["Qwen/Qwen3-8B", "meta-llama/Llama-3.1-8B"])
def test_relaunch_keeps_renamed_endpoint_name(endpoint_db, relaunched):
endpoint = _launch_llm("Qwen/Qwen3-8B", 8001)
_rename(endpoint_db, endpoint, "Workstation GPU")
assert _launch_llm(relaunched, 8001) == endpoint
assert _endpoint(endpoint_db, endpoint).name == "Workstation GPU"
def test_relaunch_names_endpoint_after_served_model_when_not_renamed(endpoint_db):
endpoint = _launch_llm("Qwen/Qwen3-8B", 8001)
for repo_id, name in [
("meta-llama/Llama-3.1-8B", "Llama-3.1-8B"),
("Qwen/Qwen3-8B", "Qwen3-8B"),
("meta-llama/Llama-3.1-8B", "Llama-3.1-8B"),
]:
_launch_llm(repo_id, 8001)
assert _endpoint(endpoint_db, endpoint).name == name
def test_launch_keeps_name_of_endpoint_without_pinned_models(endpoint_db):
import core.database as database
with endpoint_db() as db:
db.add(database.ModelEndpoint(id="manual", name="My vLLM box", base_url="http://localhost:8001/v1"))
db.commit()
assert _launch_llm("Qwen/Qwen3-8B", 8001) == "manual"
assert _endpoint(endpoint_db, "manual").name == "My vLLM box"
def test_image_relaunch_keeps_renamed_endpoint_name(endpoint_db, monkeypatch):
endpoint = _launch_image("black-forest-labs/FLUX.1-schnell", 8100, monkeypatch)
_rename(endpoint_db, endpoint, "Image box")
_launch_image("stabilityai/sdxl-turbo", 8100, monkeypatch)
assert _endpoint(endpoint_db, endpoint).name == "Image box"
def test_image_relaunch_names_endpoint_after_served_model_when_not_renamed(endpoint_db, monkeypatch):
endpoint = _launch_image("black-forest-labs/FLUX.1-schnell", 8100, monkeypatch)
_launch_image("stabilityai/sdxl-turbo", 8100, monkeypatch)
assert _endpoint(endpoint_db, endpoint).name == "sdxl-turbo (image)"
+60
View File
@@ -70,3 +70,63 @@ def test_no_gguf_found_pattern():
result = _diagnose_serve_output(out) result = _diagnose_serve_output(out)
assert result is not None assert result is not None
assert "GGUF" in result["message"] assert "GGUF" in result["message"]
def test_cookbook_routes_uses_shared_diagnosis_helper():
# /api/cookbook/tasks/status must use the maintained helper. A nested copy in
# cookbook_routes.py shadowed the import and silently drifted (missing the
# ModelOpt, mflux and MLX bridge diagnoses).
import ast
from pathlib import Path
source = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
local_defs = [
node.lineno
for node in ast.walk(ast.parse(source))
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "_diagnose_serve_output"
]
assert local_defs == []
def test_modelopt_lm_head_checkpoint_returns_diagnosis():
out = "ValueError: There is no module or parameter named 'lm_head.input_scale' in Qwen3ForCausalLM"
result = _diagnose_serve_output(out)
assert result is not None
assert "ModelOpt" in result["message"]
def test_nvidia_driver_too_old_for_torch_returns_diagnosis():
# vLLM 0.31 from PyPI ships torch cu130; on driver 570 (CUDA 12.8) the engine dies here.
out = (
"(EngineCore pid=1) ERROR [core.py:1433] self.driver_worker.init_device()\n"
"(EngineCore pid=1) ERROR [core.py:1433] RuntimeError: The NVIDIA driver on your system is too old "
"(found version 12080). Please update your GPU driver by downloading and installing a new version\n"
"Traceback (most recent call last):\n"
"RuntimeError: Engine core initialization failed. See root cause above. Failed core proc(s): {}\n"
)
result = _diagnose_serve_output(out)
assert result is not None
assert "driver" in result["message"].lower()
assert any("cu129" in s["label"] for s in result["suggestions"])
def test_cuda_runtime_newer_than_driver_returns_diagnosis():
# torch switched to cu128 but the vLLM wheel's own kernels still need CUDA 13.
out = (
"RuntimeError: get_cuda_view_from_cpu_tensor, /workspace/csrc/libtorch_stable/cuda_view.cu:38, "
"cudaHostGetDevicePointer failed: CUDA driver version is insufficient for CUDA runtime version\n"
"Traceback (most recent call last):\n"
)
result = _diagnose_serve_output(out)
assert result is not None
assert "driver" in result["message"].lower()
def test_tasks_status_capture_joins_wrapped_tmux_lines():
# tmux wraps at the pane width (often mid-word), which hid errors like
# "insufficie|nt for CUDA runtime version" from the diagnosis patterns.
from pathlib import Path
source = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
assert '_remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")' in source
assert '["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]' in source

Some files were not shown because too many files have changed in this diff Show More