Compare commits

..
Author SHA1 Message Date
Alexandre Teixeira 8257c661ad fix(ci): enforce fail-closed verification gate and redact JUnit XML secrets 2026-10-31 00:23:16 +00:00
Alexandre Teixeira 84c7eabfce fix(ci): sanitize diagnostics fail-closed and purge malformed trace records 2026-10-30 23:13:46 +00:00
Alexandre Teixeira fd79776964 Merge remote-tracking branch 'origin/dev' into test/remediation-authoritative-native-ci 2026-10-30 20:27:20 +00:00
Alexandre Teixeira 03d25943ee Merge pull request #6993 from odysseus-dev/perf/editor-cursor-style-invalidation
perf(editor): avoid brush cursor style invalidation
2026-10-30 20:09:16 +00:00
Alexandre Teixeira 71e3649985 Merge pull request #6947 from redpersongpt/codex/compare-stream-contract
fix(compare): reject incomplete and errored SSE responses
2026-10-30 19:47:28 +00:00
Alexandre Teixeira 45a57c0c38 Merge pull request #6911 from redpersongpt/codex/nested-window-close
fix(ui): return to the parent view when closing an item
2026-10-30 19:47:11 +00:00
Alexandre Teixeira 71f6dc4e5d Merge pull request #6912 from redpersongpt/codex/email-linkify-single-pass
fix(email): linkify URLs and addresses without rewriting anchors
2026-10-30 19:46:54 +00:00
Alexandre Teixeira 0c8a06a937 Merge pull request #6943 from redpersongpt/codex/chat-edit-textarea-content
fix(chat): size user-message editors from wrapped content
2026-10-30 19:46:37 +00:00
Alexandre Teixeira eea39ce103 Merge pull request #6934 from redpersongpt/codex/skill-editor-load-guard
fix(skills): wait for source before enabling the editor
2026-10-30 19:46:21 +00:00
Alexandre Teixeira 9a5a15e17f Merge pull request #6932 from redpersongpt/codex/skill-editor-source-height
fix(skills): size the raw editor from its source lines
2026-10-30 19:46:04 +00:00
Alexandre Teixeira f141ad2be5 Merge pull request #6919 from redpersongpt/codex/memory-panel-refresh
fix(memory): refresh records when opening the panel
2026-10-30 19:45:47 +00:00
Alexandre Teixeira a6e9294394 Merge pull request #6918 from redpersongpt/codex/memory-stale-category
fix(memory): reset category filter when its last entry disappears
2026-10-30 19:45:30 +00:00
Alexandre Teixeira cfbe4aeca4 Merge pull request #6915 from redpersongpt/codex/image-quality-explicit-default
fix(images): preserve explicit medium quality requests
2026-10-30 19:45:13 +00:00
Alexandre Teixeira 6f0679bb25 Merge pull request #6253 from VykosMolt/fix/generate-image-native-schema-5520
fix(tools): reject generate_image calls without a prompt
2026-10-30 19:44:56 +00:00
Alexandre Teixeira 93c93bf076 Merge pull request #6748 from dxrpl/fix/6747-recipe-panel-toggle
fix(cookbook): bring back the Dependencies recipe-panel caret (#6747)
2026-10-30 19:44:39 +00:00
Alexandre Teixeira f0f7464dfa Merge pull request #6952 from redpersongpt/codex/mail-cli-date-order
fix(mail): sort CLI message batches by UTC date
2026-10-30 19:44:23 +00:00
Alexandre Teixeira 1e57f774d3 Merge pull request #6913 from redpersongpt/codex/font-compound-style-family
fix(fonts): group compound weight and style variants by family
2026-10-30 19:44:07 +00:00
Alexandre Teixeira 2b310010cf Merge pull request #6930 from redpersongpt/codex/stale-hf-cache-markers
fix(cookbook): ignore empty markers beside completed blobs
2026-10-30 19:43:50 +00:00
Alexandre Teixeira b88154ecf9 Merge pull request #6730 from dxrpl/fix/hwfit-hf-param-count
fix(hwfit): keep the MoE active size for HF collection models (#6729)
2026-10-30 19:43:34 +00:00
Alexandre Teixeira e7796f71d3 Merge pull request #6890 from redpersongpt/codex/checkin-calendar-window-boundaries
fix(checkin): avoid duplicate calendar events at window boundaries
2026-10-30 19:43:18 +00:00
Alexandre Teixeira 412789051e Merge pull request #6585 from robyroro/codex/notes-clear-fields
fix(notes): persist removal of optional note fields
2026-10-30 19:43:01 +00:00
Alexandre Teixeira b8adc4eb60 Merge pull request #6904 from dxrpl/fix/calendar-config-object-body
fix(calendar): answer 400 when a CalDAV config body is not a JSON object (#6903)
2026-10-30 19:42:45 +00:00
Alexandre Teixeira 2246215b79 Merge pull request #6906 from dxrpl/fix/more-validation
fix(api): answer 400 for a malformed body on integrations and document export-zip (#6905)
2026-10-30 19:42:28 +00:00
Alexandre Teixeira 06fee0a3d8 Merge pull request #6546 from harshit-ojha0324/fix/degenerate-guard-numeric-data
fix(llm): stop the repeat guard from cutting off numeric data
2026-10-30 19:41:03 +00:00
Alexandre Teixeira f3813c261c perf(editor): avoid brush cursor style invalidation 2026-10-30 19:27:58 +00:00
Alexandre Teixeira e7a376b161 Merge pull request #6950 from redpersongpt/codex/group-peer-history-identity
fix(group): preserve peer identity when reloading history
2026-10-30 19:03:48 +00:00
Alexandre Teixeira fec2bb11d6 Merge pull request #6951 from redpersongpt/codex/gallery-cli-tag-filters
fix(gallery): match stacked CLI filters against AI tags
2026-10-30 19:03:01 +00:00
Alexandre Teixeira c811f76d44 Merge pull request #6935 from redpersongpt/codex/email-paperclip-files
fix(email): retain files in attachment and inline-image pickers
2026-10-30 19:02:22 +00:00
Alexandre Teixeira 02d28c06d0 Merge pull request #6888 from redpersongpt/codex/skill-version-preservation
fix(skills): preserve version strings across markdown saves
2026-10-30 19:01:47 +00:00
Alexandre Teixeira 00ecf1c74e Merge pull request #6819 from dxrpl/fix/6818-image-routes-bad-input
fix(gallery): answer 400 for a missing or invalid image on the image-edit routes (#6818)
2026-10-30 19:01:11 +00:00
Alexandre Teixeira 63feb227bb Merge pull request #6732 from dxrpl/fix/hwfit-skip-non-llm-collection-models
fix(hwfit): keep non-LLM HF collection models out of the LLM catalog (#6731)
2026-10-30 19:00:33 +00:00
Alexandre Teixeira 12fcddefab Merge pull request #6891 from redpersongpt/codex/pdf-form-unicode-field-names
fix(forms): preserve Unicode field names during PDF export
2026-10-30 18:59:47 +00:00
Alexandre Teixeira b46aa02ce1 Merge pull request #6954 from redpersongpt/codex/discovery-valid-model-ids
fix(models): retain valid discovery IDs beside malformed rows
2026-10-30 18:58:55 +00:00
Alexandre Teixeira 352ee706f3 Merge pull request #6825 from dxrpl/fix/6824-cron-five-fields
fix(tasks): refuse cron expressions with a seconds field (#6824)
2026-10-30 18:58:03 +00:00
Alexandre Teixeira 98b359cd4c Merge pull request #6982 from odysseus-dev/test/remediation-notes-process-cleanup
perf(tests): close owned Notes descendants before draining output
2026-10-30 18:53:34 +00:00
Alexandre Teixeira 2a2256af0e Merge pull request #6979 from odysseus-dev/test/remediation-static-contract-sensitivity
test: restore shadowed editor assertions and require effective procfs guards
2026-10-30 18:52:46 +00:00
Alexandre Teixeira 4f131d7c21 Merge branch 'dev' into test/remediation-notes-process-cleanup 2026-10-30 18:33:52 +00:00
Alexandre Teixeira 945e747eb4 Merge branch 'dev' into test/remediation-static-contract-sensitivity 2026-10-30 18:33:48 +00:00
Alexandre Teixeira 61051d28f0 Merge pull request #6996 from odysseus-dev/test/ci-python-baseline-repair
test(caldav): restore redirect positive-control coverage
2026-10-30 15:53:51 +00:00
PR #6029 Reviewer d87ae2a7f5 test(caldav): restore redirect positive-control coverage 2026-10-30 15:35:37 +00:00
Alexandre Teixeira 0e49c4ef40 Merge pull request #6978 from odysseus-dev/test/remediation-false-green-oracles
test: reject vacuous success in email, bridge, auth and deletion tests
2026-10-30 15:25:10 +00:00
Alexandre Teixeira 1aff71836b Merge pull request #6611 from robyroro/fix/chat-tidy-preserve-content
fix(chats): preserve saved conversations during tidy
2026-10-30 15:09:09 +00:00
Alexandre Teixeira b1c3125865 test: accept positional arguments in task endpoint mock for auto-sort 2026-10-30 15:00:08 +00:00
Alexandre Teixeira dbee1155e6 Merge pull request #6080 from alteixeira20/fix/cookbook-stale-llama-server-shim
fix(cookbook): recover from stale llama-server shims
2026-10-30 14:13:15 +00:00
Alexandre Teixeira 56c7cdf804 Merge pull request #6544 from harshit-ojha0324/fix/mcp-http-connect-leak
fix(mcp): close the HTTP transport when a remote connect fails
2026-10-30 14:09:43 +00:00
Alexandre Teixeira fe5d5caf34 Merge pull request #6977 from odysseus-dev/test/remediation-execution-isolation
test: fix pytest schema loss across route-client threads
2026-10-30 13:59:20 +00:00
Alexandre Teixeira bb654482f3 Merge pull request #6774 from odysseus-dev/fix/ios-safe-area-postmerge-hardening
fix(ui): harden iOS viewport and safe-area behavior after #6396
2026-10-30 13:56:25 +00:00
Alexandre Teixeira f45724faed Merge pull request #6983 from odysseus-dev/test/remediation-auth-fixture-seed
perf(tests): reuse immutable auth setup with fresh per-case state
2026-10-30 13:42:10 +00:00
Alexandre Teixeira 6fc6095aec test(e2e): harden mobile layout coverage, artifact isolation, and live pane assertions
- Store Playwright evidence in ignored test-results/ paths by default while preserving env override
- Clean up disposable temporary application data directories in /tmp on global teardown and exit signals
- Add Chromium mobile and tablet viewport profiles (phone portrait/landscape, tablet) for automated coverage
- Add a scoped Chromium mobile layout CI check on shard 1/4 to prevent redundant matrix execution
- Reach mobile Settings and Notes views via genuine sidebar navigation controls opened through the hamburger button
- Add assertions against live rendered Notes and Documents panes honoring safe-area top and height constraints
- Document local WebKit prerequisites and physical iOS/PWA test limitations in tests/e2e/README.md and package.json
2026-10-30 13:41:15 +00:00
Alexandre Teixeira ef16398c5c merge(dev): integrate latest dev into fix/ios-safe-area-postmerge-hardening 2026-10-30 13:25:33 +00:00
Alexandre Teixeira 6037707430 fix(cookbook): launch validated llama-server candidate and handle PATH precedence
Ensure the native candidate that passed the health check is prioritized and executed when an earlier broken candidate exists on PATH.

Reconcile Odysseus-owned shims safely, invalidate command hash cache, and verify launchability rather than command existence before serve dispatch.

Fixes #6079
2026-10-30 13:15:40 +00:00
Alexandre Teixeira 5c542d03ac Merge remote-tracking branch 'origin/dev' into fix/pr-6080-llama-recovery 2026-10-30 13:02:47 +00:00
red person 4fe8fb28e5 test: isolate photo routing from external MCP registry state 2026-10-29 23:45:05 +03:00
red person 84e232e7ee fix(cache): retain the upstream worker key format 2026-10-29 23:42:25 +03:00
red person 2cace27723 test: isolate photo routing from external MCP registry state 2026-10-29 23:40:25 +03:00
red person 508c7b3d9d Merge current dev into PR 6932 2026-10-29 23:28:45 +03:00
red person 5b8a72931d Merge current dev into PR 6934 2026-10-29 23:28:28 +03:00
red person 2e7edf908d Merge current dev into PR 6935 2026-10-29 23:27:57 +03:00
red person b80f9afb3e Merge current dev into PR 6943 2026-10-29 23:27:34 +03:00
red person 533d48ad8d Merge current dev into Compare SSE fix 2026-10-29 23:26:15 +03:00
red person d5bc7fb70e Merge current dev into group peer history fix 2026-10-29 23:21:20 +03:00
Alexandre Teixeira 3b8c60ac1e test: check database ownership against the private temp namespace 2026-10-29 17:49:45 +00:00
Alexandre Teixeira d237c3fbba perf(tests): reuse immutable admin auth configuration 2026-10-29 17:35:59 +00:00
Alexandre Teixeira f510b1973a perf(tests): bound Notes server descendant cleanup 2026-10-29 17:35:52 +00:00
Alexandre Teixeira 0eb6b9eb1d ci: gate native suites with isolated browser fixtures 2026-10-29 17:35:45 +00:00
Alexandre Teixeira fe81c491cd test: give collection database URL one cleanup owner 2026-10-29 17:30:10 +00:00
Alexandre Teixeira 1339efa856 test: restore editor aliases and validate procfs guard control flow 2026-10-29 17:12:26 +00:00
Alexandre Teixeira 6f5c28b345 test: require real cold reads, cancellation and auth writes 2026-10-29 17:12:20 +00:00
Alexandre Teixeira aa2db8d87c test: keep worker collection databases consistent across threads 2026-10-29 17:12:14 +00:00
RaresKeY 8b8fc62449 fix(docker): require the patched Debian Brotli decoder (#6956)
* fix(docker): require the patched Debian Brotli decoder

Refresh the signed Debian native Brotli package in a shared build-stage base and fail builds below the security backport floor. Verify the actual selected library, native fix marker, valid decoding, and distro Node linkage before accepting the runtime.

* fix(docker): guard HTTPS trust and accept Bookworm Brotli

Check the inherited system CA store before the shared base makes its first HTTPS package request. Accept the exact fixed Debian 12 marker alongside Debian 13 while retaining package floors, decoder ownership, and valid decoding controls.

* fix(docker): preserve Brotli gate diagnostics
2026-10-29 15:05:58 +01:00
Alexandre Teixeira 0f996f58da Merge pull request #6972 from odysseus-dev/test/env-reference-cli-fixture-performance
test: isolate environment-reference CLI state transitions
2026-10-29 10:31:08 +00:00
Alexandre Teixeira 18329268b0 Merge pull request #6974 from odysseus-dev/test/import-stub-guard-snapshot-performance
test: avoid tuple-list allocation in the import-stub guard
2026-10-29 10:30:50 +00:00
Alexandre Teixeira 6cc98f34b8 Merge pull request #6740 from odysseus-dev/codex/review-7011-preview-ui-20261015
fix(ui): integrate streaming, model picker and Notes updates
2026-10-29 10:29:51 +00:00
Alexandre Teixeira c493a66101 Merge pull request #6960 from odysseus-dev/test/remove-duplicate-editor-cache-check
test: remove duplicate editor cache-name check
2026-10-29 10:28:06 +00:00
Alexandre Teixeira d5949c0286 Merge pull request #6961 from odysseus-dev/test/restore-regression-test-sensitivity
test: restore active-document and owner-isolation sensitivity
2026-10-29 10:26:18 +00:00
Alexandre Teixeira 9ec897d947 Merge pull request #6964 from odysseus-dev/test/imap-concurrent-delete-regression
test: simulate concurrent IMAP deletion at the protocol boundary
2026-10-29 10:25:48 +00:00
Alexandre Teixeira 9257f0e6d0 Merge pull request #6965 from odysseus-dev/test/admin-route-authorization-regressions
test: enforce behavioral admin authorization on listings and diagnostics
2026-10-29 10:25:36 +00:00
Alexandre Teixeira a334a21d57 Merge pull request #6967 from odysseus-dev/test/chat-document-owner-context
test: protect chat document context owner boundaries
2026-10-29 10:24:53 +00:00
Alexandre Teixeira 87ef52f7cc test: avoid tuple-list allocation in the import-stub guard 2026-10-29 08:54:46 +00:00
Alexandre Teixeira 25617a8c10 test: isolate environment-reference CLI state transitions 2026-10-29 08:44:19 +00:00
Alexandre Teixeira 4992cc620f test: protect chat document context owner boundaries 2026-10-29 02:09:37 +00:00
Alexandre Teixeira 59cca6b915 fix(upload): canonicalize confinement on symlinked upload roots 2026-10-29 02:02:47 +00:00
Alexandre Teixeira 1ddda10716 test: exercise real admin gates before listing data 2026-10-29 01:58:21 +00:00
Alexandre Teixeira 958848f9f8 test: interleave concurrent IMAP deletion before expunge 2026-10-29 01:51:41 +00:00
Alexandre Teixeira 571ac09cea fix(security): sanitize file_id and enforce strict prefix confinement on upload paths 2026-10-29 01:39:41 +00:00
Alexandre Teixeira f979e5602f fix(upload,sw): sanitize and confine upload paths and align service worker precache token 2026-10-29 01:35:07 +00:00
Alexandre Teixeira 0f95032177 test: restore active-document and owner-isolation sensitivity 2026-10-29 01:29:04 +00:00
Alexandre Teixeira b3e2e628ab fix(upload,ui): confine thumbnail paths and align stylesheet cache-bust token 2026-10-29 01:25:07 +00:00
Alexandre Teixeira f1a45e85c5 test: remove duplicate editor cache-name assertion 2026-10-29 01:19:32 +00:00
Alexandre Teixeira f843a3dc01 merge(dev): integrate latest upstream dev (PR #6498 omit temperature from subscription requests) 2026-10-29 01:12:31 +00:00
Alexandre Teixeira 72ba37af6b merge(dev): integrate reviewed preview UI changes 2026-10-29 01:12:08 +00:00
Raul JuarezandRaul 5bf4524618 fix(chatgpt): omit temperature from subscription requests (#6498)
Co-authored-by: Raul <9117159+raultcj@users.noreply.github.com>
2026-10-29 01:34:52 +01:00
Alexandre Teixeira 1a7c79946b Merge pull request #6741 from odysseus-dev/codex/review-7011-photo-editor-20261015
feat(editor): integrate layer tools, document tabs and editor UI
2026-10-28 21:27:53 +00:00
Alexandre Teixeira 6d102a213f fix(security): confine teardown paths and avoid interpolating exception data in logs
- routes/gallery/gallery_routes.py: use stable diagnostic log message without
  interpolating raw exception, resolver details, tokens, or destination strings.
- tests/e2e/teardown.js: remove environment-variable path overrides from production
  code, strictly confine test options and temp directories, and prevent arbitrary
  directory cleanup.
- Add regression tests covering non-leakage of synthetic tokens/queries/exceptions
  into logs and responses, and path confinement during teardown.
2026-10-28 21:14:43 +00:00
Alexandre Teixeira de8f151632 fix(security): harden trace teardown and gallery error responses
- CodeQL #1202 (tests/e2e/teardown.js): replace execSync shell execution with
  execFileSync, passing python executable and sanitizer arguments separately with
  shell: false. Preserve ODYSSEUS_TEST_PYTHON support, timeout handling, and
  fail-closed deletion.
- CodeQL #1203 (routes/gallery/gallery_routes.py): prevent exception information
  exposure in /api/image/edit-progress by returning a generic rejection response
  without leaking internal hostnames, IPs, resolver errors, or credentials.
- Add regression tests covering paths with spaces, metacharacters, synthetic command
  substitutions, process failure modes, fail-closed artifact purge, and SSRF
  rejection confidentiality.
2026-10-28 21:10:12 +00:00
Alexandre Teixeira cce5892eeb test(css): update approved photo editor visual baseline
Record the Product Owner-approved 13 element changes across 48 page/variant
combinations for PR #6741. The regenerated baseline exactly matches the
visually reviewed candidate; retain the upstream PR #6840 sidebar styles.

Validation: 49 CSS snapshot/layout tests and 26 frontend/mobile browser tests.
Visual acceptance evidence remains outside the repository.
2026-10-28 20:56:09 +00:00
Alexandre Teixeira f4e3ae6e68 test(security): allowlist synthetic dummy api key in trace sanitizer test fixture
- Add gitleaks allowlist rule for synthetic test string in tests/test_trace_artifact_sanitizer.py
- Resolves Gitleaks generic-api-key false positive on GitHub Actions CI
2026-10-28 19:52:10 +00:00
Alexandre Teixeira 671cbd79ac merge(dev): integrate latest upstream dev (PR #6840 sidebar animations)
- Merge upstream/dev at 0116f0a62b
- Incorporate upstream sidebar toggle animation and auto-collapse measurement fixes
- Preserve upstream CSS snapshot baseline in tests/css_snapshot/baseline.json
- Retain all PR #6741 photo-editor features, security controls, and mobile fixes
2026-10-28 19:30:57 +00:00
Alexandre Teixeira 2e1c2a343a merge(dev): reconcile photo editor security and upstream changes
- Reconcile conflict resolution in routes/editor_agent_routes.py and src/editor_agent_model.py
- Enforce strict server-authorized endpoint resolution and reject client URL overrides
- Enforce bearer-token privilege separation: delegated API tokens are capped at non-admin scope per src/tool_security.py and cannot inspect private base URLs or probe metadata
- Preserve chat-scope authorization, missing-session 401 rejection, and owner-scoped browser bridge/SSE queues
- Preserve administrator-disabled-tool denylist enforcement introduced by PR #6357
- Fix mobile toolbar flyout hit-testing and stacking context trapping on viewports <= 700px via scoped elevation and mask suppression
- Stabilize project recovery test fixture by awaiting document draft quiescence prior to baseline capture
2026-10-28 19:30:08 +00:00
Alexandre Teixeira 0116f0a62b Merge pull request #6840 from eesoymilk/fix/sidebar-toggle-animation
fix(sidebar): animate the sidebar toggle instead of snapping
2026-10-28 18:15:15 +00:00
Alexandre Teixeira c237a1cb9c feat(editor): harden photo editor security and interactions 2026-10-28 17:37:10 +00:00
dxrpl 6b284d2bc5 test(gallery): enable local image models in the bad-input test 2026-10-28 18:27:00 +01:00
dxrpl 8acaec20ee fix(gallery): answer 400 for a missing or invalid image on the image-edit routes (#6818) 2026-10-28 18:25:04 +01:00
red person 5f18bcf6f8 fix(models): retain valid discovery IDs beside malformed rows 2026-10-28 14:52:12 +03:00
red person 18b6140511 fix(mail): sort CLI message batches by UTC date 2026-10-28 14:39:41 +03:00
red person eeb1107f7b fix(gallery): match stacked CLI filters against AI tags 2026-10-28 14:33:23 +03:00
red person 4795c8d462 fix(group): preserve peer identity when reloading history 2026-10-28 14:27:17 +03:00
red person 5c738a5a8e fix(compare): reject incomplete and errored SSE responses 2026-10-28 13:09:35 +03:00
red person 0ff5d47a8c fix(chat): size user-message editors from wrapped content 2026-10-28 12:03:20 +03:00
red person f780f1dce9 test(email): align document identity guards with picker cache 2026-10-28 10:04:55 +03:00
57a1859f08 fix(security): stop the agent from re-enabling tools an admin disabled (#6357)
* fix(security): stop the agent from re-enabling tools an admin disabled

settings.json:disabled_tools is the only global tool-permission boundary
the Settings > Agent Tools panel offers, yet manage_settings exposed an
`enable_tool` action that removed entries from it. Any agent turn could
undo the admin's restriction, and because the agent reads untrusted web
pages, emails and documents, a planted instruction could re-enable bash,
email or any other tool that had been switched off.

The denylist can now only be narrowed from a turn: `disable_tool` and
`list_tools` keep working, `enable_tool` is refused with a message that
points the user to Settings > Agent Tools, and the action is dropped from
the function schema, the prompt rule and the tool index so models are not
steered into calling it. The free-form set/delete/reset actions were
already unable to reach `disabled_tools`; a test now pins that.

Fixes #5523

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UAB5sz5srMKR7rbeDFtdMH

* fix(settings): close internal API tool-policy bypass

Refuse global tool-policy replacement from internal-token requests even when an admin owner is impersonated, and block the same write in app_api. Route explicit image re-enable requests to list_tools with a Settings pointer. Keep disabled_tools outside general settings actions even if a default is added later. Remove the retired action from capability metadata. Validation: 573 focused tests passed; the running app rejected handler, generic API and encoded-path internal HTTP attempts while preserving human administrator controls.

* fix(mcp): block agent writes to per-server MCP tool policy

PATCH /api/mcp/servers/{id}/tools only checked require_admin, which accepts
the internal tool token, and app_api still forwarded it, so an agent turn
could re-enable MCP tools an admin had switched off. Refuse the internal
token on that route (same check as POST /api/tools) and add PATCH
/api/mcp/servers to the app_api blocklist, with regression tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCbhLNC7mJsunxqfwwshqn

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Afonso Junior <chatgpt@yack.com.br>
2026-10-27 22:50:35 -06:00
red person 1c5853003e fix(email): retain inline image selections before input reset 2026-10-28 07:34:10 +03:00
red person 0f8b137713 fix(email): retain paperclip files when resetting the input 2026-10-28 07:26:05 +03:00
red person 2c5d5005ba fix(skills): wait for source before enabling the editor 2026-10-28 07:06:39 +03:00
red person 901b3ff42d fix(skills): align the chat link editor version 2026-10-28 06:58:54 +03:00
red person c207ed1ed8 fix(skills): size the raw editor from its source lines 2026-10-28 06:54:12 +03:00
red person 8b3a866899 fix(cookbook): ignore empty markers beside completed blobs 2026-10-28 06:31:50 +03:00
Alexandre Teixeira a646b59110 Merge pull request #6738 from odysseus-dev/codex/review-7011-harness-20261015
feat(harness): integrate Ajax routing, email context and memory updates
2026-10-28 01:09:22 +00:00
red person 4d8d363681 fix(memory): refresh records when opening the panel 2026-10-28 03:11:11 +03:00
red person a7e8a6ef2e fix(memory): reset category filter when its last entry disappears 2026-10-28 02:40:54 +03:00
red person 12631da4d3 fix(images): preserve explicit medium quality requests
Use configured quality only for omitted arguments in generation, MCP and edit helpers. Keep explicit medium choices from being overwritten by a low or high default.
2026-10-28 01:02:48 +03:00
red person 927985f847 fix(fonts): group compound weight and style variants by family 2026-10-28 00:23:18 +03:00
red person 2046ce893f fix(email): linkify URLs and addresses without rewriting anchors 2026-10-28 00:12:23 +03:00
red person 35ef91b25c fix(ui): return to the parent view when closing an item 2026-10-27 23:58:00 +03:00
dxrpl 91922d854c fix(api): answer 400 for a malformed body on integrations and document export-zip (#6905) 2026-10-27 18:33:23 +01:00
dxrpl 641752387b fix(calendar): answer 400 when a CalDAV config body is not a JSON object (#6903) 2026-10-27 18:09:14 +01:00
pewdiepie-archdaemon da8ba69c74 Keep streamed-chat module identity consistently cache-busted 2026-10-26 16:57:01 +00:00
pewdiepie-archdaemon 0b8176b11e Keep streamed-chat module identity consistently cache-busted 2026-10-26 16:57:01 +00:00
pewdiepie-archdaemon 375e263419 Merge backend ownership checks and test ownership cleanup 2026-10-26 16:53:52 +00:00
pewdiepie-archdaemon 106b1e7c86 Verify saved action names and steps cannot inject HTML 2026-10-26 16:50:09 +00:00
pewdiepie-archdaemon 49d57a43fe Merge recolor cleanup regression coverage 2026-10-26 16:50:09 +00:00
pewdiepie-archdaemon 2c48e7fdab Verify the Gallery model picker in the UI review that ships it 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 9ab440c4cb Keep Gallery image-model picker tests with the editor client 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon fa29a86696 Merge backend prompt hardening and scoped tests 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 8cfdb8e2e5 Render saved action labels as text instead of HTML 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 76ab72412d Preserve completion gating and handle explicit idle stream status safely 2026-10-26 16:34:56 +00:00
pewdiepie-archdaemon 56b85c359c Normalize final newlines in integrated review files 2026-10-26 16:31:23 +00:00
pewdiepie-archdaemon 04ca6c26c3 Normalize final newlines in integrated review files 2026-10-26 16:31:23 +00:00
pewdiepie-archdaemon fdb2a330d9 Remove duplicate session copy-header helpers after integration 2026-10-26 16:30:56 +00:00
pewdiepie-archdaemon 08452ab5f9 Keep chat module URLs consistent across imports and precache 2026-10-26 16:29:17 +00:00
pewdiepie-archdaemon e461a6b5c5 Keep chat module URLs consistent across imports and precache 2026-10-26 16:29:17 +00:00
pewdiepie-archdaemon c54bc1d2d3 Merge the verified editor-server foundation into the client review 2026-10-26 16:27:31 +00:00
pewdiepie-archdaemon 9ab3f70fe4 Integrate gallery document tabs with the updated editor client 2026-10-26 16:27:31 +00:00
pewdiepie-archdaemon 22144050fe Update canonical Notes modules alongside their deployment files 2026-10-26 16:23:29 +00:00
pewdiepie-archdaemon 3d47888559 Use explicit editor endpoints in backend regression fixtures 2026-10-26 16:23:29 +00:00
pewdiepie-archdaemon ace6be44f3 Merge current dev and refresh PR #6740 from the 7011 integration 2026-10-26 16:21:34 +00:00
pewdiepie-archdaemon 36a71886bb Merge current dev and refresh PR #6741 from the 7011 integration 2026-10-26 16:21:28 +00:00
red person b3f86a0724 fix(forms): preserve Unicode field names during PDF export 2026-10-26 14:04:59 +03:00
red person 94dc74e500 fix(checkin): avoid duplicate calendar events at window boundaries 2026-10-26 12:00:12 +03:00
red person fd27e9a429 fix(skills): preserve version strings across markdown saves 2026-10-26 10:32:26 +03:00
Yu-Wei Chang 76abce349b fix(sidebar): measure the auto-collapse restore with the sidebar at full width
When the window widens after an auto-collapse, the restore check removes
.hidden and measures the chat width to decide whether the sidebar fits.
html.ody-sidebar-mini also forces the sidebar to width 0 and was only
cleared by syncRailSide() after the measurement, so the check always saw
a 0-width sidebar and restored it even when the chat ended up narrower
than MIN_CHAT_WIDTH. Sync the mode classes before measuring.
2026-10-24 22:53:45 -07:00
Yu-Wei Chang 4954cc2e8c fix(sidebar): animate the sidebar toggle instead of snapping
The global .hidden utility (display: none) also matches #sidebar.hidden,
so the collapsed sidebar was removed from layout before its width/opacity
transition could run, and opening and closing both snapped in one frame.

Keep the collapsed sidebar rendered and take it out of interaction once
closed: .sidebar.hidden stays display: flex, gains pointer-events: none,
and visibility: hidden that is transitioned so it only flips after the
collapse finishes (keeping the closed sidebar out of the tab order).

On desktop the icon rail now tucks under the sidebar and slides out as it
collapses, so the combined width eases instead of jumping by 48px when
the two swap. On mobile the drawer keeps its width while off so it slides
out as well as in, and switching it to the right edge before opening no
longer animates it across the screen. Transitions are disabled under
prefers-reduced-motion, and the auto-collapse restore measures the chat
width with the transition suppressed.

Re-record the computed-style snapshot baseline for the sidebar and icon
rail.
2026-10-24 22:26:40 -07:00
dxrpl bf3c000d1c fix(tasks): refuse cron expressions with a seconds field (#6824) 2026-10-24 23:41:29 +02:00
PR #6029 Reviewer 7fd4e0e9bb fix(ui): harden iOS safe-area and viewport regressions 2026-10-20 11:39:51 +01:00
dxrplandClaude Opus 5.5 8cd96aad46 fix(cookbook): bring back the Dependencies recipe-panel caret (#6747)
cb052eee removed the caret that opens a backend's recipe panel, saying
the action had moved into the Installed menu, but that menu never got a
recipe item. The panel (model picker, commands, Run) could not be
opened, and the HW Fit / serve-card deep links that click the caret
only flashed the row. Render the caret again on every recipe row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:56 +02:00
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00
dxrplandClaude Opus 5.5 6123b70b5c fix(hwfit): keep non-LLM HF collection models out of the LLM catalog (#6731)
Tracked HF collections also contain time-series forecasters, image
encoders and image/video generators. They were imported as "general"
LLMs and offered for vllm serve. Skip their pipeline tags; embedding,
reranker and RL-tagged LLM repos stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:15:08 +02:00
dxrplandClaude Opus 5.5 bfa514f6e8 fix(hwfit): keep the MoE active size for HF collection models (#6729)
_entry_from_collection_item parsed the repo name (the only source of the
"-A3B" active size) only when HF's numParameters was missing, which is
almost never. Every MoE model from the HF collection catalog was scored
as dense, with speed estimates 3-4x too low.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:11:46 +02:00
pewdiepie-archdaemonandClaude Opus 5.5 8fda203d28 feat(ui): chat frontend, model picker, streaming, admin, a11y
Preview/UI work: chat renderer, streaming/think-trace, model picker
(instant-open/refresh-backoff), admin panel, a11y, gallery landing.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:33 +00:00
VykosMolt 1ff2fd0054 Merge dev and retain native image prompt validation 2026-10-09 01:34:21 +02:00
robyroro ad3e309074 fix(chats): preserve saved conversations during tidy 2026-10-08 06:48:41 +03:00
robyroro 15c4cef36a fix(notes): honor explicit null when clearing optional fields 2026-10-07 19:50:03 +03:00
harshit-ojha0324 84cdd866c5 fix(llm): stop the repeat guard from cutting off numeric data
The degenerate-stream guard treats a long run of one token, a dominant
token, or a periodic window as token collapse. Numbers repeat that way
by design: an 8x8 zero or identity matrix in LaTeX, a padded buffer, a
sparse 0/1 list. Each of those was stopped mid-answer with 'started
repeating tokens'.

Skip the same-run and dominance checks when the repeated token is a
number, skip the periodic check when the recent window is mostly
numbers, and skip the exact-block check when the block is all numbers.
A run of 2048 numbers in a row still stops, so a model stuck printing
'0 0 0' is still caught. Word loops and mixed word/number loops are
unchanged.

Fixes #5560
2026-10-06 19:01:12 -04:00
harshit-ojha0324 50b05bf54f fix(mcp): close the HTTP transport when a remote connect fails
_connect_http entered streamablehttp_client and ClientSession on an
AsyncExitStack but never closed it when initialize or list_tools
failed. The half-open transport was left to the garbage collector,
which exited its AnyIO cancel scope from the wrong task, and the event
loop kept spinning: about 18% of a core per failed connect, growing
with each one, until restart.

Close the stack in the connecting task on any failure or cancellation,
the same way _connect_sse already does.

Fixes #5518
2026-10-06 19:01:10 -04:00
VykosMoltandClaude dbfc96b8f3 fix(tools): add the native function schema for generate_image
generate_image existed in the prompt sections, the executor and the image_gen
MCP server, but not in FUNCTION_TOOL_SCHEMAS. API models are only sent schemas
from that list, so the tool selector could pick it and the schema filter would
drop it again — the model then improvised text-shaped calls it had no way to
make work.

The existing JSON fallback in function_call_to_tool_block already produces
content the executor parses, so no converter branch is needed. A required-args
entry is, though: without it a prompt-less call falls through to the line
parser and the whole JSON blob becomes the image prompt.

Fixes #5520

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-05 02:42:07 +02:00
Alexandre Teixeira a7c03f85bf fix(cookbook): recover from stale llama-server shims
Reconcile Odysseus-owned Python compatibility shims when llama_cpp.server is no longer available, allowing healthy native llama-server binaries in PATH to be discovered and executed without shadowing.

Fixes #6079
2026-08-17 00:59:13 +01:00
523 changed files with 55157 additions and 2147 deletions
+17
View File
@@ -0,0 +1,17 @@
"""Reject skipped/empty native CI runs without conflating driver child counts."""
import argparse
from pathlib import Path
import xml.etree.ElementTree as ET
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('report', type=Path)
parser.add_argument('--minimum', type=int, required=True)
args = parser.parse_args()
root = ET.parse(args.report).getroot()
cases = list(root.iter('testcase'))
if len(cases) < args.minimum:
raise SystemExit(f'Incomplete execution: {len(cases)} cases, expected at least {args.minimum}')
for case in cases:
if any(case.find(tag) is not None for tag in ('skipped', 'failure', 'error')):
raise SystemExit(f'Case did not pass: {case.get("classname")}::{case.get("name")}')
print(f'Verified {len(cases)} executed cases; no skipped, failed or errored cases')
+46
View File
@@ -0,0 +1,46 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { parse } from 'acorn';
const root = fileURLToPath(new URL('../../', import.meta.url));
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'tests/native-ci-manifest.json'), 'utf8'));
function files(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? files(target) : /\.(?:mjs|cjs|js)$/.test(entry.name) ? [target] : [];
});
}
function importsNativeTest(node) {
if (!node || typeof node !== 'object') return false;
if (node.type === 'ImportDeclaration' && node.source.value === 'node:test') return true;
if (node.type === 'CallExpression' && node.callee.name === 'require' && node.arguments[0]?.value === 'node:test') return true;
return Object.values(node).some(value => Array.isArray(value) ? value.some(importsNativeTest) : importsNativeTest(value));
}
const discovered = files(path.join(root, 'tests')).filter(file => {
const source = fs.readFileSync(file, 'utf8');
return source.includes('node:test') && importsNativeTest(parse(source, { ecmaVersion: 'latest', sourceType: 'module' }));
}).map(file => path.relative(root, file).split(path.sep).join('/')).sort();
const assigned = Object.values(manifest).flatMap(group => Array.isArray(group) ? group : Object.keys(group));
if (new Set(assigned).size !== assigned.length || JSON.stringify([...assigned].sort()) !== JSON.stringify(discovered)) {
throw new Error('Native test coverage must be exhaustive and disjoint; update tests/native-ci-manifest.json for added, removed or duplicated files');
}
for (const wrappers of Object.values(manifest['python-ci'])) {
for (const wrapper of wrappers) if (!fs.existsSync(path.join(root, wrapper))) throw new Error(`Missing pytest wrapper: ${wrapper}`);
}
if (process.argv.includes('--list')) {
console.log(JSON.stringify({ discovered, runners: manifest }, null, 2));
} else {
const reports = path.join(root, 'test-results/native-node');
fs.mkdirSync(reports, { recursive: true });
const result = spawnSync(process.execPath, ['--test', '--test-concurrency=1',
'--test-reporter=spec', '--test-reporter-destination=stdout',
'--test-reporter=junit', `--test-reporter-destination=${path.join(reports, 'junit.xml')}`,
...manifest['native-ci']], {
cwd: root, stdio: 'inherit',
env: { ...process.env, ENDPOINT_URL: 'http://127.0.0.1:1' },
});
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
}
+423
View File
@@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""Fail-closed independent verification and sanitization gate for test diagnostics.
Runs after test suite completion or abnormal runner termination (e.g. failures, timeouts, crashes).
Ensures that no unsanitized traces, raw credentials, temporary archives, or unauthorized
diagnostics can ever be published as CI artifacts, while preserving safe JUnit XML reporting.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import shutil
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
# Add repository root to Python path to import sanitizer
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
try:
from scripts.sanitize_trace_artifacts import sanitize_trace_archive
except ImportError:
# If import fails, define fail-closed fallback
def sanitize_trace_archive(zp: str) -> bool:
p = Path(zp)
if p.exists():
try:
p.unlink(missing_ok=True)
except OSError:
pass
return False
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("verify_sanitized_artifacts")
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
ALLOWED_JUNIT_ROOT_TAGS = frozenset({"testsuites", "testsuite"})
ALLOWED_JUNIT_ELEMENT_TAGS = frozenset({
"testsuites",
"testsuite",
"testcase",
"failure",
"error",
"skipped",
"system-out",
"system-err",
"properties",
"property",
})
SENSITIVE_KEY_PATTERN = re.compile(
r"(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|"
r"private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)"
)
SECRET_REDACTION_PATTERNS = [
# 1. Authorization headers (Bearer / Basic)
re.compile(r"(?i)\b(bearer|basic)\s+([^\s,;\'\"<>]+)"),
# 2. Sensitive key-value pairs (key=value or key: value)
re.compile(
r"(?i)\b([a-z0-9_.-]*(?:password|passwd|secret|token|api_key|apikey|access_token|refresh_token|"
r"auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)[a-z0-9_.-]*)"
r"\s*([:=])\s*([^\s,;\'\"<>]+)"
),
# 3. URL credentials (scheme://user:pass@host)
re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://[^:\s]+):([^@\s]+)@"),
# 4. JSON-formatted key-value pairs
re.compile(
r'(?i)("?(?:[a-z0-9_.-]*(?:password|passwd|secret|token|key|auth)[a-z0-9_.-]*)"?\s*:\s*")([^"]+)(")'
),
# 5. Synthetic secret keywords or tokens
re.compile(r"(?i)\b[a-z0-9_.-]*synthetic[-_]?[a-z0-9_.-]*\b"),
]
def safe_remove(filepath: str | Path) -> bool:
"""Safely remove a file or directory fail-closed.
Returns True if the target does not exist or was successfully removed.
Returns False if removal failed and the target still exists on disk.
"""
p = Path(filepath)
if not p.exists():
return True
try:
if p.is_dir():
shutil.rmtree(p, ignore_errors=False)
else:
p.unlink(missing_ok=True)
except OSError as exc:
logger.error("Failed to delete unauthorized file %s: %s", filepath, exc)
return False
if p.exists():
logger.error("Target %s still exists on disk after deletion attempt", filepath)
return False
return True
def sanitize_text_content(text: str) -> tuple[str, bool]:
"""Scrub sensitive credentials, tokens, and synthetic secrets from text."""
if not text or not isinstance(text, str):
return text, False
modified = False
out = text
# 1. Replace Bearer / Basic tokens
def _sub_auth(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)} [REDACTED]"
out = SECRET_REDACTION_PATTERNS[0].sub(_sub_auth, out)
# 2. Replace key-value pairs
def _sub_kv(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)}{m.group(2)}[REDACTED]"
out = SECRET_REDACTION_PATTERNS[1].sub(_sub_kv, out)
# 3. Replace URL credentials
def _sub_url(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)}:[REDACTED]@"
out = SECRET_REDACTION_PATTERNS[2].sub(_sub_url, out)
# 4. Replace JSON key-value secrets
def _sub_json(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)}[REDACTED]{m.group(3)}"
out = SECRET_REDACTION_PATTERNS[3].sub(_sub_json, out)
# 5. Replace synthetic secret markers
def _sub_synthetic(m: re.Match) -> str:
nonlocal modified
modified = True
return "[REDACTED]"
out = SECRET_REDACTION_PATTERNS[4].sub(_sub_synthetic, out)
return out, modified
def sanitize_and_verify_junit_xml(filepath: str | Path) -> bool:
"""Verify that an XML file is an authentic JUnit report and redact credentials.
Returns True if the XML file is a valid recognized JUnit report and was safely sanitized.
Returns False if the file is invalid, unrecognized, corrupted, or failed sanitization.
"""
p = Path(filepath)
name_lower = p.name.lower()
# 1. Filename restriction: must match standard JUnit report naming
if not (name_lower == "junit.xml" or name_lower == "browser-junit.xml" or name_lower.endswith("-junit.xml") or name_lower.endswith(".junit.xml")):
logger.warning("Unrecognized XML report filename %s; not an authorized JUnit report", filepath)
return False
# 2. Parse XML
try:
tree = ET.parse(p)
except Exception as exc:
logger.warning("Corrupted XML report %s: %s", filepath, exc)
return False
root = tree.getroot()
# 3. Root tag restriction
if root.tag.lower() not in ALLOWED_JUNIT_ROOT_TAGS:
logger.warning("XML report %s has invalid root element <%s>; not a JUnit report", filepath, root.tag)
return False
# 4. Check element tags and redact sensitive content
file_modified = False
for elem in tree.iter():
if elem.tag.lower() not in ALLOWED_JUNIT_ELEMENT_TAGS:
logger.warning("XML report %s contains unrecognized element tag <%s>", filepath, elem.tag)
return False
# Sanitize attributes
for k, v in list(elem.attrib.items()):
if SENSITIVE_KEY_PATTERN.search(k.lower()):
elem.attrib[k] = "[REDACTED]"
file_modified = True
elif isinstance(v, str):
new_v, changed = sanitize_text_content(v)
if changed:
elem.attrib[k] = new_v
file_modified = True
# Sanitize text
if elem.text:
new_text, changed = sanitize_text_content(elem.text)
if changed:
elem.text = new_text
file_modified = True
# Sanitize tail
if elem.tail:
new_tail, changed = sanitize_text_content(elem.tail)
if changed:
elem.tail = new_tail
file_modified = True
# 5. If content was sanitized, write back safely
if file_modified:
try:
tree.write(str(p), encoding="utf-8", xml_declaration=True)
# Verify rewritten XML parses cleanly
ET.parse(p)
except Exception as exc:
logger.error("Failed to write sanitized XML report %s: %s", filepath, exc)
return False
return True
def is_valid_disposable_ci_fixture(target_dir: str | Path | None = None) -> bool:
"""Verify that execution ran inside an authorized disposable synthetic fixture."""
is_ci = bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"))
if not is_ci:
# Local runs require explicit trace opt-in
trace_opt_in = os.environ.get("PLAYWRIGHT_ENABLE_TRACE", "").lower() in ("1", "true")
return trace_opt_in
if os.environ.get("ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS") != "1":
return False
if os.environ.get("PHOTO_EDITOR_E2E_AUTH") == "0":
return False
allowed_tmp = [os.path.abspath(p) for p in (tempfile.gettempdir(), "/tmp", "/var/tmp")]
# Check 1: Verify fixture ownership via PHOTO_EDITOR_E2E_DATA_DIR
data_dir = os.environ.get("PHOTO_EDITOR_E2E_DATA_DIR")
if data_dir:
resolved = os.path.abspath(data_dir)
base_name = os.path.basename(resolved)
is_tmp = any(resolved.startswith(b + os.sep) or resolved == b for b in allowed_tmp)
if base_name.startswith("odysseus-photo-editor-e2e-") and is_tmp:
expected_db = os.path.join(resolved, "app.db")
db_path = os.environ.get("PHOTO_EDITOR_E2E_DB_PATH")
if not db_path or db_path == expected_db:
marker_path = os.path.join(resolved, ".disposable-ci-fixture.json")
if os.path.exists(marker_path):
try:
with open(marker_path, "r", encoding="utf-8") as f:
fixture = json.load(f)
if (
fixture.get("synthetic") is True
and fixture.get("authenticated") is True
and fixture.get("database") == expected_db
):
return True
except Exception:
pass
# Check 2: Verify fixture proof in target_dir if provided
if target_dir:
target_marker = os.path.join(str(target_dir), ".disposable-ci-fixture.json")
if os.path.exists(target_marker):
try:
with open(target_marker, "r", encoding="utf-8") as f:
fixture = json.load(f)
db_in_fixture = fixture.get("database", "")
resolved_db = os.path.abspath(db_in_fixture)
db_dir = os.path.dirname(resolved_db)
db_base = os.path.basename(db_dir)
is_tmp = any(db_dir.startswith(b + os.sep) or db_dir == b for b in allowed_tmp)
if (
fixture.get("synthetic") is True
and fixture.get("authenticated") is True
and db_base.startswith("odysseus-photo-editor-e2e-")
and is_tmp
and os.path.basename(resolved_db) == "app.db"
):
return True
except Exception:
pass
return False
def verify_and_sanitize_artifacts(target_dir: str) -> bool:
"""Independently verify and sanitize diagnostics fail-closed, preserving safe JUnit XML.
Returns True if verification succeeded and artifacts are safe for publication.
Returns False if verification failed or deletion could not be confirmed.
"""
if not os.path.exists(target_dir):
logger.info("Target directory %s does not exist; nothing to sanitize.", target_dir)
return True
disposable_ci = is_valid_disposable_ci_fixture(target_dir)
preserved_xmls = 0
sanitized_archives = 0
purged_files = 0
overall_success = True
try:
for root, dirs, files in os.walk(target_dir, topdown=False):
for filename in files:
filepath = os.path.join(root, filename)
name_lower = filename.lower()
# Cleanup disposable fixture marker so it is not published
if filename == ".disposable-ci-fixture.json":
if not safe_remove(filepath):
overall_success = False
continue
# 1. XML reports: verify JUnit structure and sanitize credentials
if name_lower.endswith(".xml"):
if sanitize_and_verify_junit_xml(filepath):
preserved_xmls += 1
continue
else:
logger.warning("Rejecting invalid/unrecognized XML report %s; purging fail-closed", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
continue
# 2. Check if diagnostics are authorized (disposable synthetic fixture)
if not disposable_ci:
logger.info("Purging diagnostic artifact %s (disposable CI fixture not verified)", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
continue
# 3. Disposable CI is authorized: enforce strict sanitization
if name_lower.endswith(".zip"):
ok = sanitize_trace_archive(filepath)
if ok and os.path.exists(filepath):
sanitized_archives += 1
else:
logger.warning("Trace archive %s failed sanitization; purging fail-closed", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
elif name_lower.endswith(".png"):
# Validate PNG magic bytes to ensure not a secret payload disguised as an image
is_png = False
try:
with open(filepath, "rb") as pf:
header = pf.read(8)
if header == PNG_MAGIC:
is_png = True
except Exception:
is_png = False
if is_png:
continue
logger.warning("Invalid or suspicious screenshot %s; purging fail-closed", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
else:
# Purge any .tmp, .har, .db, .sqlite, .log or unrecognized files
logger.info("Purging unauthorized diagnostic file %s", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
# Remove empty subdirectories
for d in dirs:
dirpath = os.path.join(root, d)
try:
if not os.listdir(dirpath):
os.rmdir(dirpath)
except OSError:
pass
if not overall_success:
logger.error("Verification failed: one or more unauthorized files could not be purged.")
return False
logger.info(
"Artifact verification complete for %s: preserved %d XML reports, sanitized %d archives, purged %d files.",
target_dir,
preserved_xmls,
sanitized_archives,
purged_files,
)
return True
except Exception as exc:
logger.error("Unexpected error during artifact verification: %s; purging all non-XML files fail-closed", exc)
try:
for p in Path(target_dir).rglob("*"):
if p.is_file() and not p.name.lower().endswith(".xml"):
safe_remove(p)
except Exception:
pass
return False
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("target_dir", nargs="?", default="test-results", help="Directory containing test results")
args = parser.parse_args()
ok = verify_and_sanitize_artifacts(args.target_dir)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
+7
View File
@@ -256,3 +256,10 @@ jobs:
env:
PYTEST_SHARD: ${{ matrix.shard }}
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
- name: Playwright mobile layout checks (shard 1/4 only)
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
run: |
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
--config tests/e2e/ios-layout.config.cjs \
--reporter=line
+129
View File
@@ -0,0 +1,129 @@
name: Native test suites
on:
pull_request:
push:
branches: [main, dev]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: native-tests-${{ github.ref }}
cancel-in-progress: true
jobs:
native-node:
name: Native Node regressions
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.23.4'
cache: npm
- run: npm ci --ignore-scripts --no-audit --no-fund
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
- name: Validate exhaustive disjoint runner mapping and execute omitted suites
run: |
node .github/scripts/run_native_tests.mjs
python .github/scripts/check_test_execution.py test-results/native-node/junit.xml --minimum 43
- name: Verify and sanitize native test results fail-closed
id: verify-native-results
if: always()
run: |
python .github/scripts/verify_sanitized_artifacts.py test-results/native-node/
- name: Save native test identities and results
if: always() && steps.verify-native-results.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: native-node-results
path: test-results/native-node/
retention-days: 7
if-no-files-found: warn
browser:
name: Chromium application regressions
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS: '1'
ODYSSEUS_TEST_PYTHON: python
CHROMADB_HOST: 127.0.0.1
CHROMADB_PORT: '1'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
- name: Install FFmpeg and establish system trust
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg ca-certificates
python scripts/check_ca_trust.py --system
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: '0.13.5'
enable-cache: true
cache-dependency-glob: requirements.txt
prune-cache: false
- run: |
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
python scripts/check_ca_trust.py --python
uv pip install --system -r requirements.txt
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.23.4'
cache: npm
- run: npm ci --ignore-scripts --no-audit --no-fund
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
- name: Run complete Chromium suite against a fresh authenticated app
env:
PLAYWRIGHT_JUNIT_OUTPUT_FILE: test-results/browser-junit.xml
run: |
npm run test:photo-editor -- --reporter=line,junit
python .github/scripts/check_test_execution.py test-results/browser-junit.xml --minimum 137
- name: Verify and sanitize failure diagnostics fail-closed
id: verify-browser-results
if: always()
run: |
python .github/scripts/verify_sanitized_artifacts.py test-results/
- name: Save per-test results and sanitized failure diagnostics
if: always() && steps.verify-browser-results.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: chromium-regression-results
path: test-results/
retention-days: 7
if-no-files-found: warn
authoritative-native-suites:
name: Authoritative native suites
if: always()
needs: [native-node, browser]
runs-on: ubuntu-24.04
steps:
- name: Require both complete suites to succeed
env:
NODE_RESULT: ${{ needs.native-node.result }}
BROWSER_RESULT: ${{ needs.browser.result }}
run: |
test "$NODE_RESULT" = success
test "$BROWSER_RESULT" = success
+2
View File
@@ -53,6 +53,8 @@ services/data/
# Test capture artifacts (browser session dumps may contain personal content)
.playwright-mcp/
test-results/
playwright-report/
# OS
.DS_Store
+12
View File
@@ -35,3 +35,15 @@ regexTarget = "secret"
regexes = [
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
]
[[allowlists]]
description = "Synthetic dummy API key in trace artifact sanitizer test fixture"
targetRules = ["generic-api-key"]
condition = "AND"
paths = [
'''(?:^|/)tests/test_trace_artifact_sanitizer\.py$''',
]
regexTarget = "secret"
regexes = [
'''^sk-123456789$''',
]
+18 -3
View File
@@ -1,9 +1,22 @@
# Both stages need the fixed native decoder, including curl in the builder and
# distro Node in the runtime. A cached Python base may predate the Debian fix.
FROM python:3.14.9-slim AS brotli-base
COPY scripts/check_brotli.py /usr/local/bin/check-brotli.py
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
# Check inherited trust before the first HTTPS package request, retaining Debian's
# signed archive verification. Refresh only the explicit native dependency.
RUN python /usr/local/bin/check-ca-trust.py --system \
&& sed -i 's|http://deb.debian.org/|https://deb.debian.org/|g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends libbrotli1 \
&& python /usr/local/bin/check-brotli.py \
&& rm -rf /var/lib/apt/lists/*
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
# the final image / Cookbook never has to compile the broken sdists. See
# docker/build-realesrgan-wheels.sh for the full rationale.
FROM python:3.14.9-slim AS realesrgan-wheels
FROM brotli-base AS realesrgan-wheels
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
@@ -14,7 +27,7 @@ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
FROM python:3.14.9-slim
FROM brotli-base
# System deps. tmux is required by Cookbook for background downloads/serves.
# openssh-client is required for Cookbook remote server tests, setup, probes,
@@ -52,6 +65,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0t64 \
libxcb1 \
libmagic1 \
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node \
&& rm -rf /var/lib/apt/lists/*
# Refresh both Python trust bundles before installing application dependencies.
@@ -132,7 +146,8 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
# Check gRPC's effective defaults after all Python dependencies are installed.
# The application minimum fixes its bundled roots; the checked override also
# keeps the effective Docker selection consistent with system trust.
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc \
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node
# Copy app code
COPY . .
+255
View File
@@ -0,0 +1,255 @@
# Quick Edit Coverage Audit
Audit of how much of the PhotoCraft editor is reachable through **Quick Edit**
(the natural-language editor agent). Built against build **0.20.44** (HARNESS
bridge fan-out fix `4dd11826`) and client commit `e78b5a96`.
Quick Edit executes in two stages:
1. **Plan** — `static/js/editor/editor-agent.js` posts `{prompt, state,
screenshot}` to `/api/editor/agent`. HARNESS `routes/editor_agent_routes.py`
plans with the shared style guide (`src/editor_style_guide.py`) plus an LLM,
validates every step against its own `OP_CATALOG`, then executes.
2. **Execute** — only `ai_edit_image` / `generate_image` run server-side; every
other op is delivered to the page over the editor bridge (SSE) and run by the
client control surface (`window.geEditorControl.invoke`, catalog in
`static/js/galleryEditor.js`). A local fallback planner lives in
`static/js/editor/nl-edit.js`.
There are **three** catalogs that must agree:
| Catalog | File | Owner |
|---|---|---|
| Client control ops | `static/js/galleryEditor.js` | PHOTOSHOP |
| Server `OP_CATALOG` | `routes/editor_agent_routes.py` | HARNESS |
| Style guide / planner | `src/editor_style_guide.py` + `nl-edit.js` | HARNESS + PHOTOSHOP |
Legend: **C** = in client catalog · **S** = in server `OP_CATALOG` (incl.
`NEW_OP_NAMES`) · **P** = the planner/style guide emits it for some phrasing.
A capability is **usable from Quick Edit** only when the planner can emit it
(**P**) and the op exists on the side that runs it.
---
## 1. Menu bar (12 menus)
| Capability | Trigger phrasing | Op | C | S | P | Status |
|---|---|---|---|---|---|---|
| File → Import | "open this image" | `document.importImage` | ✔ | ✔ | partial | OK |
| File → Load project | "open my project" | `document.openPsd` | ✔ | ✘ | ✘ | **Server gap** |
| File → Save / Save copy / Export PSD | "save as psd" | `document.savePsd` | ✔ | ✔ | ✔ | OK |
| File → Export | "export png" | `export.png` | ✔ | ✔ | ✔ | OK |
| File → Save project | — | (project store) | n/a | n/a | ✘ | Out of scope (not an edit op) |
| Edit → Undo/Redo/History | "undo" | `history.undo` / `redo` | ✔ | ✔ | ✔ | OK |
| Edit → Free Transform | "free transform" | `transform.start` | ✔ | ✔ | ✔ | OK |
| Edit → Transform → Rotate 90/180 | "rotate this layer 90" | `layer.rotate` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) |
| Edit → Transform → Flip H/V | "flip this layer" | `layer.flip` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) |
| Image → Fill | "fill the whole image with white" | — | ✘ | ✘ | ✘ | **No op** |
| Image → Canvas Size | "make the canvas wider" | `document.resizeCanvas` | ✔ | ✔ | partial | OK |
| Image → Image Size | "resize the image to 1200px" | `document.resizeCanvas` | ✔ | ✔ | partial | OK |
| Image → Rotate 90/180 (whole image) | "rotate the whole image 90" | `document.rotate`/all-layers | ✘ | ✘ | ✘ | **No control op** |
| Image → Flip H/V (whole image) | "flip the whole canvas" | `document.flip`/all-layers | ✘ | ✘ | ✘ | **No control op** |
| Image → Mode RGB/Grayscale | "make it black and white" | `adjustments.apply` black-white | ✔ | ✔ | ✔ | OK (mode via adjustment) |
| Image → Adjustments → Invert | "invert the colours" | `adjustments.apply` invert | ✔ | ✔ | ✔ | OK (fast path colour-inverts the active layer; "invert the selection" stays Select ▸ Inverse) |
| Image → Mode CMYK/Lab, Bits 16/32 | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) |
| Layer → New | "add a layer" | `layers.addRaster` | ✔ | ✔ | partial | OK |
| Layer → Duplicate | "duplicate the layer" | `layers.duplicate` | ✔ | ✔ | partial | OK |
| Layer → Group | "group the layers" | `layers.group` | ✔ | ✔ | ✘ | **Server OK, planner gap** |
| Layer → Rasterize | "rasterize the text" | `layers.rasterize` | ✔ | ✔ | ✔ | OK |
| Layer → Delete | "delete the layer" | `layers.delete` | ✔ | ✔ | partial | OK |
| Layer → Merge Down | "merge down" | `layers.mergeDown` | ✔ | ✘ | ✘ | **Server gap** |
| Layer → Merge All / Flatten | "flatten the image" | `layers.flatten` | ✔ | ✘ | ✘ | **Server gap** |
| Layer → Select All | "select all layers" | (multi-select) | ✔ | ✔ | ✔ | OK (fast path selects every layer) |
| Layer → Combine (unite/subtract/…) | "combine the shapes" | `_combineSelectedShapes` | ✔ | ✔ | ✔ | OK (fast path selects the shape layers and combines) |
| Layer → Fill | "fill the layer with blue" | `selection.fill` / flood | partial | ✘ | ✘ | **Gap** |
| Layer → Layer Mask (reveal/hide/apply/invert/feather) | "add a layer mask" | `layers.addMaskFromSelection` | ✔ | ✔ | ✔ | OK |
| Layer → Opacity | "set the layer opacity to 50%" | `layers.setOpacity` | ✔ | ✔ | partial | OK (fast path sets the active layer's opacity) |
| Layer → Blend Mode | "set the blend mode to multiply" | `layers.setBlend` | ✔ | ✔ | partial | OK (fast path sets the active layer's blend mode) |
| Type → Add | "add a title" | `text.add` | ✔ | ✔ | ✔ | OK |
| Type → Character | "make the text bigger" | `text.edit` | ✔ | ✘ | ✘ | **Server gap + planner gap** |
| Type → Paragraph | "centre the text" | `text.layout` | ✔ | ✔ | partial | OK |
| Type → Warp | "warp the text" | `_rerenderTextLayer` (warp) | ✔ | ✔ | ✔ | OK |
| Select → All/Deselect/Invert | "invert the selection" | `selection.*` | ✔ | ✔ | ✔ | OK |
| Select → Reselect | "reselect" | `_reselectSelection` | ✔ | ✔ | ✔ | OK |
| Select → Transform | "transform the selection" | `transform.start` | ✔ | ✔ | partial | OK |
| Select → Refine | "refine the edge" | `selection.feather/expand` | ✔ | ✘ | ✘ | **Server gap** |
| Select → Color Range | "select the blue sky" | `selection.colorRange` | ✔ | ✔ | partial | OK |
| Select → Select and Mask | "select the subject" | `selection.subject` | ✔ | ✔ | ✔ | OK |
| Select → Save | "save the selection" | `_saveNamedSelection` | ✔ | ✔ | ✔ | OK |
| Select → Load | "load the selection as sky" | `_loadNamedSelection` | ✔ | ✔ | ✔ | OK |
| Filter → every filter (30+) | "pixelate the image" | `filters.apply` | ✔ | ✔ | ✔ | OK (fast path covers pixelate/emboss/twirl/pinch/spherize/ripple/solarize/find-edges/crystallize/noise/despeckle/unsharp + blur/sharpen/vignette) |
| Filter → Blur → Motion / Zoom | "motion blur" / "zoom blur" | `filters.apply` (blur-motion / blur-zoom) | ✔ | ✔ | ✔ | OK (fast path maps motion blur and zoom blur to their Filter-menu variants) |
| Filter → Layer effects (drop shadow, stroke, colour overlay, outer glow) | "add a drop shadow" | `layers.addStyle` | ✔ | ✔ | ✔ | OK (fast path adds drop shadow / stroke / outer glow / colour & gradient overlay; colour parsed from the prompt) |
| View → Rulers/Grid/Snap/Clear guides | "show the grid" | `#ge-view-menu` | ✔ | ✔ | ✔ | OK |
| Window → panels | — | n/a | n/a | n/a | ✘ | UI-only |
| 3D / Plugins | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) |
## 2. Tools
Toolbar tools are interactive, not bridge ops, so coverage is about whether the
**effect** of a tool is reachable by prompt.
| Tool | NL-reachable? | Notes |
|---|---|---|
| Move / Transform | `transform.*` | OK (transform.start/apply/cancel) |
| Hand / Zoom | — | View only |
| Crop | `document.crop` + format guard | OK |
| Text / Shape | `text.add`, `text.layout` | Shape has no add-shape op — **gap** |
| Brush / Eraser / Dodge / Burn / Smudge / Clone / Heal / Sharpen | — | Interactive painting; no NL op — **gap** (out of scope for planner) |
| Gradient | — | **No op** |
| **Paint Bucket** | `fill.flood` | **Server gap + planner gap** (op exists client-side only) |
| Eyedropper | — | UI only |
| Marquee / Lasso / Wand / Quick Select / Object Select / Pen / Path | `selection.rectangle/ellipse/polygon/subject/…` | OK for region/reference selects; lasso/pen paths have `selection.polygon` only |
| SAM / Inpaint / Rembg | `selection.subject`, `ai.removeObject`, `ai.removeBackground` | OK |
| **Pixel brush + grid** | — | UI state only; no op — **gap** (acceptable: it is a brush mode, not an edit op) |
## 3. Right-click context menu
| Item | Backing op | Reachable by prompt? |
|---|---|---|
| Brush size / preset / Pixel Brush | `state` UI | No (interactive) |
| Select All / Deselect / Inverse / Feather / Expand / Contract / Border / Smooth | `selection.*` | Yes |
| Transform Selection / Refine Edge | `transform.start`, `selection.feather` | Partial |
| Apply / Cancel Crop | `document.crop` | Yes |
| Commit / Cancel / Free Transform | `transform.apply/cancel/start` | Yes |
| Add Text Layer / Rasterize Text | `text.add`, `layers.rasterize` | Yes (rasterize OK) |
| Copy Color Hex / Swap Colors | `state.color` | OK (fast path swaps / resets the foreground & background swatches) |
| Fill Selection w/ Foreground | `selection.fill` | OK (fast path fills the selection/canvas with the foreground or background colour) |
| Cut / Copy / Paste | `selection.copy/paste` | **Gap** (no planner intent) |
| Rotate 90/180, Flip H/V | `layer.rotate/flip` | **Server+planner gap** (same as Edit menu) |
| Undo / Redo | `history.*` | Yes |
## 4. Adjustments
`adjustments.apply` / `adjustments.applyMasked` cover all adjustment types
(brightness-contrast, white-balance, shadows-highlights, vibrance, clarity,
vignette, black-white, …). The style guide emits several. **Status: OK**
(planner covers the common ones; the long tail is reachable only via explicit
wording).
## 5. Filters
`filters.apply` / `filters.applyMasked` run every filter the UI lists. The
planner only emits **sharpen**, **blur-gaussian**, and **vignette** via intents.
**Gap (planner):** no intent for noise, pixelate, stylize, distort, render,
unsharp, sharpen-edges, colour-overlay/drop-shadow/stroke.
**Gap (HARNESS):** `filters.apply`/`applyMasked` are in the server catalog, so
the fix is style-guide intents, not catalog.
## 6. Layer styles
`layers.addStyle {type, params}` exists in both catalogs. **Planner never emits
it**, so "add a drop shadow to the title", "add a stroke", "colour overlay" fail.
**Gap (planner).**
## 7. Selection ops
`selection.rectangle/ellipse/polygon/fill/delete/copy/paste/feather/expand/
contract/selectAll/deselect/invert/colorRange/subject/samText/face/sky/
background/hasMask/maskBounds/exportMask/regionLuminance`. **Status: mostly OK.**
Missing: reselect, save-selection.
## 8. Document ops
| Capability | Op | C | S | P |
|---|---|---|---|---|
| Crop | `document.crop` | ✔ | ✔ | ✔ |
| Resize canvas / image | `document.resizeCanvas` | ✔ | ✔ | partial |
| **Extend canvas** | `document.extend` | ✔ | ✘ | ✘ |
| Save PSD | `document.savePsd` | ✔ | ✔ | ✔ |
| Export layers | `document.exportLayers` | ✔ | ✘ | ✘ |
| Duplicate / Create / Switch / List | `document.*` | ✔ | ✘ | partial |
## 9. AI edit & generate modes
| Mode | Op | Notes |
|---|---|---|
| Generative fill | `ai.generativeFill` | OK |
| Remove object | `ai.removeObject` | OK |
| Harmonize | `ai.harmonize` | OK |
| Upscale | `ai.generativeUpscale` | OK |
| Generate image | `ai.generateImage` / server `generate_image` | OK |
| Expand | `ai.generativeExpand` | OK (content-bounds prefill, SW v760) |
| Remove background | `ai.removeBackground` | OK |
| Instruction edit / scene replace | server `ai_edit_image` | OK (HARNESS) |
---
## 10. Gap list
### Client-side (fix in this repo — PHOTOSHOP-owned) — **ALL FIXED**
1. ✅ **`fill.flood` not in the NL path** — added to `nl-edit.js OP_CATALOG` +
`localPlan` fill intent ("fill … with <colour>"; falls back to `selection.fill`
when a region is selected).
2. ✅ **`layer.flip` / `layer.rotate` not in the NL path** — added to
`nl-edit.js OP_CATALOG` + "this layer" intent (whole-image phrasing routes to
`document.flip`/`document.rotate`).
3. ✅ **`text.edit` not in the NL path** — added catalog entry + intent for font
size/colour ("make the text bigger/smaller", "make it red").
4. ✅ **`document.extend` not in the NL path** — added catalog entry + intent for
"extend the canvas to 16:9" (derives target size from the document).
5. ✅ **`layers.align`** — added planner intent ("align … to the centre/left/…").
6. ✅ **`layers.addStyle`** — added planner trigger ("drop shadow", "stroke",
"colour overlay").
7. ✅ **Whole-image rotate/flip has no control op** — added `document.rotate` /
`document.flip` control ops (`galleryEditor.js`) and catalog/intent entries.
8. ✅ **`selection.copy/paste` intents** — added "copy the selection"/"paste".
### Server-side (file for HARNESS in the passover)
1. `routes/editor_agent_routes.py OP_CATALOG` is missing ops the client runs:
`layer.flip`, `layer.rotate`, `text.edit`, `fill.flood`, `layers.align`,
`layers.rasterize`, `layers.mergeDown`, `layers.flatten`, `document.extend`,
`document.exportLayers`, `document.*` (list/duplicate/create/switch/openPsd),
`mask.*`, `selection.*` (polygon/feather/expand/contract/copy/paste/hasMask/
face), `transform.*` are partly present.
2. `src/editor_style_guide.py` has no intents for: layer transform
(rotate/flip active layer), whole-document rotate/flip, text editing, align,
layer styles (drop shadow/stroke/overlay), flood fill, copy/paste, noise/
pixelate/stylize/distort/render filters.
## 11. Coverage summary
Counting distinct user-facing capabilities in the toolbar + 12 menus + right-click
(~150 including the filter/effect long tail):
- **Have a client op:** ~85%
- **Client op also in server OP_CATALOG:** ~75%
- **Planner can emit for natural phrasing:** ~72% (was ~55%; client-side
planner intents for fill, layer/whole-image transform, text edit, align,
layer styles, copy/paste and extend are now implemented)
- **Usable from Quick Edit end-to-end today:** **45/45 prompts (100%)** measured
live on build **0.20.45** with `tests/e2e/photo-editor/quickedit-coverage.js`
(`/tmp/opencode/qe-coverage45/quickedit-coverage.json`). HARNESS build 0.20.45
(`4a6932a2`) added the 12 missing structural ops to the server `OP_CATALOG` +
style guide v5, so every audited prompt now plans to the exact op.
The gap was dominated by the **planner**, not by missing client functionality;
that planner gap is now closed end-to-end (30/45 on 0.20.44 -> 45/45 on 0.20.45).
## 12. Test prompts (~40)
See `docs/QUICKEDIT_PROMPTS.md` and the runner
`tests/e2e/photo-editor/quickedit-coverage.js` for per-prompt pass/fail.
### Live run (build 0.20.45) — 45/45 PASS
All 45 prompts plan to the exact expected op. HARNESS 0.20.45 routes these
structural ops server-side: `fill.flood`, `text.edit`, `layers.align`,
`layer.rotate`/`layer.flip`, `document.rotate`/`document.flip`,
`selection.copy`/`selection.paste`, `layers.flatten`, `layers.mergeDown` (plus
the previously passing set). Results:
`/tmp/opencode/qe-coverage45/quickedit-coverage.json`.
### Previous run (build 0.20.44) — 30/45 PASS
Kept for provenance. Failures were all the server planner emitting a generic
`adjustments.apply`/`transform.apply` or a wrong op; ids 1, 2, 3, 4, 8, 9, 10,
11, 12, 13, 14, 19, 20, 40, 41. All now fixed in 0.20.45.
## Provenance
Recon inventory captured from `static/js/editor/build/topbar.js`,
`static/js/editor/build/toolbar.js`, `static/js/editor/canvas-context-menu.js`,
`static/js/galleryEditor.js` (`installEditorControl`), `static/js/editor/nl-edit.js`,
`routes/editor_agent_routes.py`, and `src/editor_style_guide.py`.
+46
View File
@@ -0,0 +1,46 @@
# Native test merge gate
`Native test suites` runs on pull requests, dev/main pushes and manual dispatch.
`Authoritative native suites` fails unless both the native Node and complete
Chromium jobs succeed; it has no continue-on-error, retries or path bypass.
Repository administrators must require this check alongside the four pytest
checks in branch protection. The audited public dev branch had no protection or
ruleset, so merely adding workflow jobs cannot prevent a manual merge.
`tests/native-ci-manifest.json` exhaustively partitions all native Node files:
16 run in the native job,14 through existing pytest wrappers, one through the
existing frontend architecture CI step, and the formerly live email-reply file
through an explicitly identified Playwright driver. The manifest validator fails
for omitted/duplicate/unreachable entries. Node emits individual JUnit identities
and propagates its failure exit. No native file is newly run in each pytest shard.
The browser job uses the existing one-worker Chromium config and all 136 Photo
Editor cases (including cursor and brush regressions), plus one driver for the native
email-reply contract (137 cases total). Its ephemeral account,
SQLite DB and server are created by the config/setup; reuseExistingServer remains
false. NPX startup is offline/cache-only in a private cache; Notes/editor behavior
and Python MCP services remain real. No developer app or external account is used.
Default CI trace suppression remains intact. Only this job's explicit opt-in plus
a newly generated authenticated synthetic fixture marker permits failure traces.
The existing structured sanitizer runs before artifacts are uploaded; invalid
ownership, failed sanitization and missing tooling purge sensitive artifacts.
Unsupported or malformed sensitive records cause the affected archive to be removed
fail-closed. An independent fail-closed verification step (.github/scripts/verify_sanitized_artifacts.py)
runs after test suite execution or abnormal runner termination to prevent publishing
unsanitized diagnostics while preserving safe JUnit reporting. The verifier returns a nonzero
exit code upon any inspection or deletion failure, publishable XML is restricted to recognized
JUnit reports with sensitive tokens redacted, and workflow upload steps execute only when
independent verification succeeds (steps.<id>.outcome == 'success').
Never upload the fixture directory, database or browser storage state.
The stroke budget now excludes measured synthetic event-delivery pacing and also
bounds actual Chromium main-thread work at the original5s. All240 samples and
pixel/thumbnail/undo/redo assertions remain. Per-case timing attachments expose
both clocks rather than treating driver frame cadence as application work.
Three Bombadil properties remain opt-in: they require a Bombadil/browser scenario
and bounded fuzz campaign; no authoritative execution is claimed. The two model
smoke scripts require supplied model endpoints/models and remain explicit live
operations. No credentials or live-service authorization are implied by CI setup.
Firefox/WebKit alternatives remain opt-in and are not certified by Chromium CI.
@@ -112,7 +112,7 @@ export function createCardInteractions({
if (state.selectMode) {
body.querySelectorAll('.note-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection
e.stopPropagation();
tapToEditOrSelect(card);
});
+2 -2
View File
@@ -51,7 +51,7 @@ export function createEditor({
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
</div>
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button></div>` : ''}
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button></div>` : ''}
<div class="note-form-body">
${type === 'note'
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
@@ -297,7 +297,7 @@ export function createEditor({
form.querySelector('.note-form-image-wrap')?.remove();
const wrap = document.createElement('div');
wrap.className = 'note-form-image-wrap';
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button>`;
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button>`;
// Insert AFTER the whole header (a flex-row), not after the
// title input itself : otherwise the image lands as a sibling
// of the title inside the header and flex puts them side-by-side.
+1 -1
View File
@@ -31,7 +31,7 @@ export function createText({
url = url.slice(0, -1);
}
const href = url.startsWith('www.') ? `https://${url}` : url;
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
});
}
+2 -2
View File
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
"prompt": {"type": "string", "description": "Image description prompt"},
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default configured quality, otherwise medium)"},
},
"required": ["prompt"],
},
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality == "medium" and _settings.get("image_quality"):
if "quality" not in arguments and _settings.get("image_quality"):
quality = _settings["image_quality"]
# Auto-detect best available image model
+5 -1
View File
@@ -9,7 +9,11 @@
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
"test:photo-editor:install": "playwright install chromium firefox webkit",
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:install": "playwright install chromium webkit"
},
"devDependencies": {
"@antithesishq/bombadil": "^0.7.0",
+4
View File
@@ -887,6 +887,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"""Create a new integration (admin only)."""
_require_admin_user(request)
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
item = add_integration(body)
return {"ok": True, "integration": mask_integration_secret(item)}
@@ -895,6 +897,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"""Update an existing integration (admin only)."""
_require_admin_user(request)
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
item = update_integration(integration_id, body)
if not item:
raise HTTPException(404, "Integration not found")
+16 -16
View File
@@ -80,6 +80,18 @@ def _require_user(request: Request) -> str:
return FALLBACK_OWNER
async def _json_object_body(request: Request) -> dict:
"""Parse a JSON object body. An unreadable body counts as empty; a body
that is valid JSON but not an object (a list, string or number) is a 400."""
try:
body = await request.json()
except Exception:
return {}
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
return body
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
if not cal:
@@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
async def save_config(request: Request):
"""Legacy single-account endpoint — upserts the first account."""
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
accounts = _get_caldav_accounts(owner)
if not (body.get("url") or "").strip():
_save_caldav_accounts(owner, [])
@@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
"""Add a new CalDAV account."""
import uuid as _uuid
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
from src.caldav_sync import validate_caldav_url
try:
url = validate_caldav_url(body.get("url", ""))
@@ -1080,10 +1086,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
async def update_caldav_account(account_id: str, request: Request):
"""Update an existing CalDAV account by id."""
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
accounts = _get_caldav_accounts(owner)
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
if idx is None:
@@ -1124,10 +1127,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
test an already-saved account. Falls back to the first saved account
when nothing is provided."""
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
url = (body.get("url") or "").strip()
user = (body.get("username") or "").strip()
pw = body.get("password") or ""
+165 -1
View File
@@ -14,6 +14,7 @@ from fastapi import HTTPException
from pydantic import BaseModel
from routes._validators import validate_remote_host, validate_ssh_port
from routes.cookbook_output import HF_PARTIAL_BLOB_HELPER
from core.platform_compat import _ssh_exec_argv
logger = logging.getLogger(__name__)
@@ -378,6 +379,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
"""
lines = [
"import json, os, re, shutil, subprocess, urllib.request",
*HF_PARTIAL_BLOB_HELPER.splitlines(),
"models = []",
"seen = set()",
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
@@ -436,8 +438,10 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" sz, nf, ic = 0, 0, False",
" if os.path.isdir(blobs):",
" for f in os.scandir(blobs):",
" if f.name.endswith('.incomplete'):",
" if not is_partial_blob(f.path): continue",
" ic = True",
" if f.is_file(): nf += 1; sz += f.stat().st_size",
" if f.name.endswith('.incomplete'): ic = True",
" snap = os.path.join(cache, d, 'snapshots')",
" def snapshot_size():",
" total, count, incomplete = 0, 0, False",
@@ -1030,6 +1034,166 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
ODYSSEUS_LLAMA_SHIM_MARKER = "# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike"
def _append_llama_server_resolution_definition_lines(runner_lines: list[str]) -> None:
"""Define the helper shell function that discovers, reconciles and prioritizes llama-server."""
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
runner_lines.append('_odysseus_resolve_llama_server() {')
runner_lines.append(' _odysseus_llama_bin=""')
runner_lines.append(f' local _marker={marker}')
runner_lines.append(' local _saved_ifs="$IFS"')
runner_lines.append(" IFS=':'")
runner_lines.append(' local -a _parts')
runner_lines.append(' read -r -a _parts <<< "$PATH"')
runner_lines.append(' IFS="$_saved_ifs"')
runner_lines.append(' local _dir _cand')
runner_lines.append(' for _dir in "${_parts[@]}"; do')
runner_lines.append(' [ -n "$_dir" ] || continue')
runner_lines.append(' _cand="$_dir/llama-server"')
runner_lines.append(' [ -x "$_cand" ] || continue')
runner_lines.append(' if [ -f "$_cand" ] && [ ! -L "$_cand" ] && grep -Fxq "$_marker" "$_cand" 2>/dev/null; then')
runner_lines.append(' continue')
runner_lines.append(' fi')
runner_lines.append(' if "$_cand" --version >/dev/null 2>&1; then')
runner_lines.append(' _odysseus_llama_bin="$_cand"')
runner_lines.append(' break')
runner_lines.append(' fi')
runner_lines.append(' done')
runner_lines.append(' if [ -f "$HOME/bin/llama-server" ] && [ ! -L "$HOME/bin/llama-server" ] && grep -Fxq "$_marker" "$HOME/bin/llama-server" 2>/dev/null; then')
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
runner_lines.append(' echo "[odysseus] Native llama-server detected — retiring Odysseus compatibility shim in ~/bin"')
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
runner_lines.append(' hash -r')
runner_lines.append(' elif python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' _odysseus_llama_bin="$HOME/bin/llama-server"')
runner_lines.append(' else')
runner_lines.append(' echo "[odysseus] Removing stale Odysseus llama-server shim (python3 -m llama_cpp.server unavailable)"')
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
runner_lines.append(' hash -r')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
runner_lines.append(' local _cand_dir')
runner_lines.append(' _cand_dir="$(dirname "$_odysseus_llama_bin")"')
runner_lines.append(' if [ "${PATH%%:*}" != "$_cand_dir" ]; then')
runner_lines.append(' export PATH="$_cand_dir:$PATH"')
runner_lines.append(' fi')
runner_lines.append(' hash -r')
runner_lines.append(' return 0')
runner_lines.append(' fi')
runner_lines.append(' return 1')
runner_lines.append('}')
def _append_llama_server_shim_definition_lines(runner_lines: list[str]) -> None:
"""Define the helper shell function that generates the Odysseus compatibility shim."""
_append_llama_server_resolution_definition_lines(runner_lines)
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
runner_lines.append('_odysseus_create_llama_shim() {')
runner_lines.append(' local _target="$HOME/bin/llama-server"')
runner_lines.append(f' local _marker={marker}')
runner_lines.append(' if [ -e "$_target" ] || [ -L "$_target" ]; then')
runner_lines.append(' if [ -f "$_target" ] && [ ! -L "$_target" ] && grep -Fxq "$_marker" "$_target" 2>/dev/null; then')
runner_lines.append(' : # existing Odysseus shim — safe to replace')
runner_lines.append(' else')
runner_lines.append(' echo "[odysseus] WARNING: $_target already exists and is not an Odysseus-managed shim; leaving untouched." >&2')
runner_lines.append(' return 1')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' mkdir -p "$HOME/bin" || return 1')
runner_lines.append(' local _tmp')
runner_lines.append(' _tmp="$(mktemp "$HOME/bin/.llama-server.odysseus.tmp.XXXXXX")" || {')
runner_lines.append(' echo "[odysseus] ERROR: unable to create secure temporary llama-server shim in $HOME/bin" >&2')
runner_lines.append(' return 1')
runner_lines.append(' }')
runner_lines.append(" if ! cat > \"$_tmp\" <<'_ODY_LLAMA_SHIM_EOF'")
runner_lines.append('#!/usr/bin/env bash')
runner_lines.append(ODYSSEUS_LLAMA_SHIM_MARKER)
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
runner_lines.append('ARGS=()')
runner_lines.append('while [ $# -gt 0 ]; do')
runner_lines.append(' case "$1" in')
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
runner_lines.append(' --no-warmup) shift ;; # native-only')
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
runner_lines.append(' esac')
runner_lines.append('done')
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
runner_lines.append(' then')
runner_lines.append(' rm -f -- "$_tmp"')
runner_lines.append(' return 1')
runner_lines.append(' fi')
runner_lines.append(' chmod +x "$_tmp" || { rm -f -- "$_tmp"; return 1; }')
runner_lines.append(' mv -f -- "$_tmp" "$_target" || { rm -f -- "$_tmp"; return 1; }')
runner_lines.append(' hash -r')
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
runner_lines.append('}')
def _append_llama_server_shim_reconciliation_lines(runner_lines: list[str]) -> None:
"""Reconcile an existing ~/bin/llama-server if it is owned by Odysseus Cookbook.
Invariants:
- Never touch an arbitrary user-owned executable or symlink.
- If ~/bin/llama-server is an Odysseus generated shim:
- If a native llama-server is present and healthy elsewhere on PATH, remove the shim
so the native binary is used.
- If python3 -m llama_cpp.server is unavailable, remove the stale shim
and hash -r so the fallback/build path is triggered.
- If no healthy native server exists and llama_cpp.server is working, preserve it.
- If a healthy native candidate exists on PATH (even later in PATH), prioritize it and
ensure the launched executable is the validated candidate.
"""
runner_lines.append('[ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env')
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
_append_llama_server_resolution_definition_lines(runner_lines)
runner_lines.append('fi')
runner_lines.append('_odysseus_resolve_llama_server || true')
def _append_llama_server_finalization_lines(runner_lines: list[str], platform_hint: str = "") -> None:
"""Reconcile post-install launcher availability and enforce fail-closed invariant.
Guarantees that if python3 -m llama_cpp.server is usable but the `llama-server`
command is unresolvable, the compatibility launcher is created. If no usable
launcher exists after all build/install attempts, sets ODYSSEUS_PREFLIGHT_EXIT=127.
"""
err_msg = (
f"ERROR: llama.cpp serving is not available on {platform_hint} after install attempts."
if platform_hint
else "ERROR: llama.cpp serving is not available after install/build attempts."
)
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
_append_llama_server_resolution_definition_lines(runner_lines)
runner_lines.append('fi')
runner_lines.append('if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' _odysseus_create_llama_shim')
runner_lines.append(' _odysseus_resolve_llama_server || true')
runner_lines.append('fi')
runner_lines.append('if [ -z "$_odysseus_llama_bin" ]; then')
runner_lines.append(f' echo "{err_msg}"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
"""Shell command that clears the Cookbook-managed llama.cpp build.
+18 -4
View File
@@ -8,6 +8,20 @@ import re
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
# Shared source embedded in local/remote inventory and status probes. A blank
# retry marker beside its completed, nonempty content-addressed blob is stale;
# a blank marker alone may be a download that has not received its first byte.
HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
if not path.endswith('.incomplete'):
return False
try:
completed = path[:-len('.incomplete')]
return (os.path.getsize(path) != 0 or not os.path.isfile(completed)
or os.path.getsize(completed) == 0)
except OSError:
return True
"""
# Probe scripts for the dead-session download check, run as
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
# cache_root is the task's custom download dir, '' for the default HF cache.
@@ -15,7 +29,7 @@ _FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
# the probe process's own environment knows nothing about it.
HF_CACHE_COMPLETE_PROBE = (
"import os,sys;"
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
"repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
@@ -24,18 +38,18 @@ HF_CACHE_COMPLETE_PROBE = (
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
"inc=False;"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
"sys.exit(0 if ok and not inc else 1)"
)
HF_CACHE_INCOMPLETE_PROBE = (
"import os,sys;"
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
"repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
"sys.exit(0 if inc else 1)"
)
+17 -57
View File
@@ -57,9 +57,11 @@ from routes.cookbook_helpers import (
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
_diagnose_serve_output, run_ssh_command_async,
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
_normalize_llama_cpp_python_cache_types,
_append_llama_server_shim_definition_lines,
_append_llama_server_shim_reconciliation_lines,
_append_llama_server_finalization_lines,
ModelDownloadRequest, ServeRequest,
)
@@ -2161,7 +2163,7 @@ def setup_cookbook_routes() -> APIRouter:
ps_lines.append('}')
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
ps_lines.append('# Auto-install llama-cpp-python if missing')
ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}')
ps_lines.append('try { python -c "import llama_cpp.server" 2>$null } catch {}')
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
ps_lines.append(' python -m pip install llama-cpp-python[server]')
@@ -2247,14 +2249,17 @@ def setup_cookbook_routes() -> APIRouter:
# ollama is found (otherwise macOS falls back to a slow source build).
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
_append_llama_server_shim_definition_lines(runner_lines)
_append_llama_server_shim_reconciliation_lines(runner_lines)
runner_lines.append('if [ -d /data/data/com.termux ]; then')
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' if ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' pkg install -y cmake 2>/dev/null')
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
runner_lines.append(' fi')
runner_lines.append('elif ! command -v llama-server &>/dev/null; then')
_append_llama_server_finalization_lines(runner_lines, platform_hint="Termux")
runner_lines.append('elif [ -z "$_odysseus_llama_bin" ]; then')
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
runner_lines.append(' mkdir -p ~/bin')
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
@@ -2271,8 +2276,7 @@ def setup_cookbook_routes() -> APIRouter:
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
# explicit so the binary is optimized (Metal auto-enables on macOS).
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\')
runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server')
runner_lines.append(' else')
_append_llama_cpp_linux_accel_build_lines(runner_lines)
runner_lines.append(' fi')
@@ -2296,58 +2300,13 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' fi')
# SHORT-CIRCUIT before the build/pip fallback: if the
# native binary is missing but llama_cpp Python is already
# installed, drop a wrapper at ~/bin/llama-server that
# translates llama-server CLI args to llama_cpp.server's
# underscore-style flags. The user's serve command stays
# `llama-server ...` and "just works" — no build, no cmake,
# no second install. This is the path that unblocks every
# remote where pip-installed llama-cpp-python is already
# working but Cookbook used to insist on a native binary.
runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' mkdir -p ~/bin')
runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'')
runner_lines.append('#!/usr/bin/env bash')
runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike')
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
runner_lines.append('ARGS=()')
runner_lines.append('while [ $# -gt 0 ]; do')
runner_lines.append(' case "$1" in')
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
runner_lines.append(' --no-warmup) shift ;; # native-only')
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
runner_lines.append(' esac')
runner_lines.append('done')
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
runner_lines.append(' chmod +x ~/bin/llama-server')
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
runner_lines.append(' fi')
runner_lines.append(' # If the native build failed, fall back to the Python bindings.')
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' _odysseus_resolve_llama_server || true')
# If the native build failed, fall back to the Python bindings.
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
runner_lines.append(' fi')
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
_append_llama_server_finalization_lines(runner_lines)
runner_lines.append('fi')
elif re.search(r"\bollama\s+serve\b", req.cmd):
handled_ollama_serve = True
@@ -4343,7 +4302,7 @@ def setup_cookbook_routes() -> APIRouter:
tmux output can stop at a stale progress line if the pane/session
disappears before Cookbook captures the final DOWNLOAD_OK marker.
In that case, trust the cache shape: a snapshot directory with files
and no *.incomplete blobs means HuggingFace finished materializing the
and no unfinished blobs means HuggingFace finished materializing the
model. cache_root is the task's custom download dir — the runner
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
not wherever this probe's environment says.
@@ -4368,7 +4327,8 @@ def setup_cookbook_routes() -> APIRouter:
"""Best-effort check for resumable HF partial blobs.
A lost SSH/tmux session can leave a real download still incomplete.
Treat any *.incomplete blob as stronger evidence than stale
Treat unfinished blobs (excluding empty markers paired with a
completed blob) as stronger evidence than stale
"100%" lines in the captured pane output.
"""
if not repo_id or "/" not in repo_id:
+3 -1
View File
@@ -781,9 +781,11 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
except Exception as e:
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
data = {}
ids = data.get("ids") or []
ids = data.get("ids") if isinstance(data, dict) else None
if not ids:
raise HTTPException(400, "No documents specified")
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
raise HTTPException(400, "ids must be a list of document ids")
_ext = {
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
+81 -13
View File
@@ -1203,19 +1203,64 @@ def _bridge_timeout(value: Any) -> float:
def _is_admin(request: Request) -> bool:
"""Return True only for authorized administrator browser sessions or auth-disabled single-user mode.
Delegated credentials (bearer API tokens) are explicitly rejected:
per src/tool_security.py, bearer tokens are long-lived credentials handed to
third parties/scripts and must remain capped at non-admin policies even
when minted by an administrator.
"""
# 1. Delegated credentials (bearer API tokens) must NEVER receive admin metadata privileges
if getattr(request.state, "api_token", False):
return False
# 2. In-process internal tool bypass (loopback agent tools)
try:
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
import secrets
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
return True
if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER:
return True
except Exception:
pass
# 3. Explicitly auth-disabled single-user operation (AUTH_ENABLED=false)
try:
from src.auth_helpers import _auth_disabled
if _auth_disabled():
return True
except Exception:
pass
# 4. Middleware require_admin
try:
from core.middleware import require_admin
require_admin(request)
return True
except Exception:
return False
pass
# 5. Direct session/user check when request.state.current_user is set
try:
auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_mgr and getattr(auth_mgr, "is_configured", False):
from src.auth_helpers import get_current_user
user = get_current_user(request) or getattr(request.state, "current_user", None)
if user and getattr(auth_mgr, "is_admin", None) and auth_mgr.is_admin(user):
return True
except Exception:
pass
return False
def _user(request: Request) -> Optional[str]:
# These owner-scoped editor APIs have no delegated-token scope contract.
# Do not collapse all bearer users into the shared "api" pseudo-owner.
# Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner
if getattr(request.state, "api_token", False):
raise HTTPException(403, "API tokens must use a scope-aware API route")
from src.auth_helpers import require_api_token_scope
return require_api_token_scope(request, "chat")
from src.auth_helpers import get_current_user, _auth_disabled
u = get_current_user(request)
if u:
@@ -1358,9 +1403,13 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception:
raw = None
_used = editor_agent_model.get_last_model()
model_used = (_used[0] if _used and _used[0]
else (agent_model or editor_agent_model.local_model()))
parsed = _parse_plan(raw)
if _used and _used[0]:
model_used = _used[0]
elif parsed:
model_used = agent_model or editor_agent_model.local_model()
else:
model_used = "fallback" if agent_model else editor_agent_model.local_model()
if isinstance(parsed, dict):
parsed["agent_model"] = model_used
if mode != "check":
@@ -1634,9 +1683,14 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception:
raw = None
_used = editor_agent_model.get_last_model()
model_used = (_used[0] if _used and _used[0]
else (agent_model or editor_agent_model.local_model()))
plan = _parse_plan(raw) or _fallback_plan(prompt)
parsed = _parse_plan(raw)
if _used and _used[0]:
model_used = _used[0]
elif parsed:
model_used = agent_model or editor_agent_model.local_model()
else:
model_used = "fallback" if agent_model else editor_agent_model.local_model()
plan = parsed or _fallback_plan(prompt)
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
if _strengthened != plan.get("steps"):
plan = {**plan, "steps": _strengthened,
@@ -1723,6 +1777,12 @@ def setup_editor_agent_routes() -> APIRouter:
"results": [],
"agent_model": model_used,
})
needed = {"plan_edit"}
for step in steps:
op = step.get("op")
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
if needed.intersection(disabled):
raise HTTPException(403, "Editor tool is disabled")
results: List[Dict[str, Any]] = []
for step in steps:
op = step.get("op")
@@ -1865,14 +1925,19 @@ def setup_editor_agent_routes() -> APIRouter:
# recognisably image-capable — the same test the request
# router uses.
image_endpoint = getattr(ep, "model_type", None) == "image"
endpoint_id = getattr(ep, "id", None)
provider = getattr(ep, "name", None)
if not provider:
provider = getattr(ep, "base_url", "") if is_admin else "image"
provider = (
getattr(ep, "base_url", "")
if is_admin
else (f"Endpoint {endpoint_id}" if endpoint_id else "image")
)
provider = provider or "image"
for name in _endpoint_enabled_models(ep):
if not image_endpoint and not looks_like_image_generation_model(name):
continue
key = (provider, name)
key = (str(endpoint_id or provider), name)
if key in seen:
continue
seen.add(key)
@@ -1881,7 +1946,7 @@ def setup_editor_agent_routes() -> APIRouter:
"label": name,
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
"provider": provider,
"endpoint_id": getattr(ep, "id", None),
"endpoint_id": endpoint_id,
}
if is_admin:
entry["base_url"] = getattr(ep, "base_url", "") or ""
@@ -1896,11 +1961,12 @@ def setup_editor_agent_routes() -> APIRouter:
async def editor_agent_models(request: Request) -> JSONResponse:
"""List the models the editor agent/planner can run on.
Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is
Each entry is ``{id, label, source, vision}`` where ``id`` is
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
``source`` is ``local`` (the local Qwen3.5-9B vision server) or
``hosted`` (the configured default/utility chat model that the planner
falls back to).
For non-admin users, private URLs and internal probe status are omitted.
"""
owner = _user(request)
is_admin = _is_admin(request)
@@ -1916,12 +1982,14 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception:
logger.warning("editor agent-models lookup failed", exc_info=True)
models = []
probe_info = {}
if is_admin:
probe_info = editor_agent_model.probe_status()
if not probe_info:
editor_agent_model.startup_probe()
probe_info = editor_agent_model.probe_status()
return JSONResponse({
"models": models,
"probe": probe_info,
+3 -2
View File
@@ -19,9 +19,10 @@ def _split_family_token(token):
def _derive_family(filename):
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
name = os.path.splitext(filename)[0]
# Strip common weight/style suffixes
# Strip weight/style suffixes, including BoldItalic and Bold-Italic.
name = re.sub(
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)$',
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)'
r'(?:[-_ ]?(?:Italic|Oblique))?$',
'', name, flags=re.IGNORECASE
)
# Replace dashes/underscores with spaces
+39 -26
View File
@@ -683,9 +683,15 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st
configured.update(v for v in values if isinstance(v, str))
except (TypeError, ValueError):
pass
if model not in configured or not looks_like_image_generation_model(model):
continue
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target:
if model:
if model not in configured or not looks_like_image_generation_model(model):
continue
else:
if not any(looks_like_image_generation_model(v) for v in configured):
continue
ep_id = str(getattr(ep, "id", "") or "")
ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", ""))
if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target):
if owner and getattr(ep, "owner", None) == owner:
return ep
if fallback is None:
@@ -712,7 +718,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
)
if not ok:
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
async with httpx.AsyncClient(timeout=60) as c2:
async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2:
ir = await c2.get(url)
if ir.status_code == 200:
return base64.b64encode(ir.content).decode()
@@ -1730,7 +1736,7 @@ def setup_gallery_routes() -> APIRouter:
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
# SSRF hardening: validate a client-supplied endpoint before any
# outbound request (mirrors routes/embedding_routes.py).
if requested_base:
if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
requested_base,
@@ -2076,7 +2082,7 @@ def setup_gallery_routes() -> APIRouter:
runs off the event loop (large images).
"""
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
if requested_base:
if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
requested_base,
@@ -2169,26 +2175,38 @@ def setup_gallery_routes() -> APIRouter:
try:
ep = None
if endpoint_id:
ep = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id),
ModelEndpoint, user,
).first()
if ep is None and requested_base:
ep = _visible_image_endpoint_for_base(db, endpoint_id, user)
if ep is None:
return {"available": False, "reason": "endpoint not found or disabled"}
elif requested_base:
ep = _visible_image_endpoint_for_base(db, requested_base, user)
if ep is None:
if ep is None:
return {"available": False, "reason": "endpoint not found or disabled"}
else:
ep = _first_visible_image_endpoint(db, user)
if ep is None:
return {"available": False, "reason": "no image endpoint configured"}
base = ep.base_url.rstrip("/")
finally:
db.close()
from src.url_safety import check_outbound_url
block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true"
ok, reason = check_outbound_url(base, block_private=block_private)
if not ok:
# CodeQL #1203: Never expose internal addresses, resolver errors or raw
# exception details to clients or logs. Use a stable diagnostic message
# without interpolating untrusted exception content or destination strings.
logger.warning("edit-progress endpoint rejected: outbound URL policy check failed")
return {"available": False, "reason": "endpoint URL rejected"}
if not base.endswith("/v1"):
base += "/v1"
# Dynamic path segment (validated token above), so not routed through
# _join_checked_gallery_endpoint's exact-match whitelist.
url = f"{base}/images/progress/{request_id}"
try:
async with httpx.AsyncClient(timeout=10) as client:
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
r = await client.get(url)
if r.status_code == 200:
data = r.json()
@@ -2226,7 +2244,7 @@ def setup_gallery_routes() -> APIRouter:
# below, so validate it first (mirrors routes/embedding_routes.py).
# Local-first means loopback/LAN is allowed by default; the cloud
# metadata range and non-HTTP(S) schemes are always rejected.
if requested_base:
if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
requested_base,
@@ -2253,7 +2271,7 @@ def setup_gallery_routes() -> APIRouter:
# so the outbound URL never depends directly on request-body input.
db = SessionLocal()
try:
ep = _visible_image_endpoint_for_base(db, requested_base, user)
ep = _visible_image_endpoint_for_base(db, requested_base, user, model)
if not ep:
raise HTTPException(403, "Choose a registered image endpoint")
base = ep.base_url.rstrip("/")
@@ -2415,11 +2433,10 @@ def setup_gallery_routes() -> APIRouter:
image_b64 = body.get("image")
amount = body.get("amount", 50) / 100.0
from PIL import Image, ImageFilter
from PIL import ImageFilter
import base64, io
img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
img = _b64_to_pil_image(image_b64, mode="RGB")
# Unsharp mask: radius=2, percent=amount*200, threshold=3
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3))
@@ -2454,8 +2471,7 @@ def setup_gallery_routes() -> APIRouter:
except ImportError:
raise HTTPException(500, "Server missing a required dependency")
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
img_bytes = _b64_bytes(image_b64)
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
src = _b64_to_pil_image(image_b64, mode="RGB")
try:
patch_realesrgan_torchvision_compat()
from realesrgan import RealESRGANer
@@ -2507,8 +2523,7 @@ def setup_gallery_routes() -> APIRouter:
import numpy as np
except ImportError:
raise HTTPException(500, "Server missing a required dependency")
img_bytes = _b64_bytes(image_b64)
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
src = _b64_to_pil_image(image_b64, mode="RGB")
try:
patch_realesrgan_torchvision_compat()
from basicsr.archs.rrdbnet_arch import RRDBNet
@@ -2760,8 +2775,7 @@ def setup_gallery_routes() -> APIRouter:
from PIL import Image
import base64, io
img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
img = _b64_to_pil_image(image_b64)
W, H = img.size
hint = None
@@ -2850,8 +2864,7 @@ def setup_gallery_routes() -> APIRouter:
from PIL import Image, ImageFilter, ImageEnhance
import numpy as np
img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
img = _b64_to_pil_image(image_b64, mode="RGB")
# Try GFPGAN first (AI face restoration)
try:
+7 -1
View File
@@ -5,6 +5,7 @@ import os
import uuid
import urllib.parse
import html
import secrets
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, HTMLResponse
@@ -12,7 +13,7 @@ import logging
import httpx
from core.database import McpServer, SessionLocal
from core.middleware import require_admin
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, require_admin
from src.constants import DATA_DIR, MCP_OAUTH_DIR
from src.mcp_manager import McpManager
@@ -424,6 +425,11 @@ def setup_mcp_routes(mcp_manager: McpManager):
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
"""
require_admin(request)
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
# Same boundary as POST /api/tools: an agent turn must not be able
# to re-enable MCP tools an admin switched off.
raise HTTPException(403, "Change MCP tool policy in Settings > Integrations, not from chat")
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
+7
View File
@@ -7,6 +7,7 @@ import json
import hashlib
import ipaddress
import socket
import secrets
import time as _time
import logging
import threading
@@ -3091,6 +3092,12 @@ def setup_model_routes(model_discovery):
def update_tools(body: ToolsUpdate, request: Request):
"""Update which tools are disabled."""
require_admin(request)
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
# Owner impersonation can carry an admin username, so check the
# authenticated transport rather than request.state.current_user.
raise HTTPException(403, "Change tool policy in Settings > Agent Tools, not from chat")
settings = _load_settings()
settings["disabled_tools"] = body.disabled
_save_settings(settings)
+12 -9
View File
@@ -728,32 +728,35 @@ def setup_note_routes(task_scheduler=None, upload_handler=None):
)
if body.title is not None:
note.title = body.title
if body.content is not None:
# Omitted fields preserve their values; explicit null clears the
# nullable fields used by the editor's removal controls.
supplied = body.model_fields_set
if "content" in supplied:
note.content = body.content
if body.items is not None:
note.items = json.dumps(body.items)
if "items" in supplied:
note.items = json.dumps(body.items) if body.items is not None else None
flag_modified(note, "items")
if body.note_type is not None:
note.note_type = body.note_type
if body.color is not None:
if "color" in supplied:
note.color = body.color
if body.label is not None:
if "label" in supplied:
note.label = body.label
if body.pinned is not None:
note.pinned = body.pinned
if body.archived is not None:
note.archived = body.archived
if body.due_date is not None:
if "due_date" in supplied:
note.due_date = body.due_date
if body.image_url is not None:
if "image_url" in supplied:
note.image_url = body.image_url
if body.gallery_id is not None:
if "gallery_id" in supplied:
note.gallery_id = body.gallery_id
if body.repeat is not None:
note.repeat = body.repeat
if body.sort_order is not None:
note.sort_order = body.sort_order
if body.agent_session_id is not None:
if "agent_session_id" in supplied:
note.agent_session_id = body.agent_session_id
db.commit()
+7 -40
View File
@@ -1315,7 +1315,7 @@ def setup_session_routes(
def auto_sort_sessions(request: Request, skip_llm: bool = False):
"""Use AI to categorize all sessions into folders.
Phase 1 deletes empty/throwaway sessions and Phase 2 asks the LLM
Phase 1 deletes empty/Incognito sessions and Phase 2 asks the LLM
to assign folders. When `skip_llm=true` the endpoint returns
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
users can clean junk without spending tokens.
@@ -1326,21 +1326,11 @@ def setup_session_routes(
single_user_mode = not user and _auth_disabled()
user_sessions = session_manager.get_sessions_for_user(user)
# Delete empty and throwaway sessions before sorting
# Saved conversations are never disposable based on their title or prompt.
from core.database import ChatMessage as DbMsg
db = SessionLocal()
deleted_empty = 0
deleted_throwaway = 0
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
_THROWAWAY_NAMES = {
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
"yo", "sup", "hola", "hii", "hiii", "heyo",
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
"new chat", "delete", "remove", "junk", "trash", "xxx",
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
"ok", "lol", "bruh", "hmm", "hm", "meh",
}
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
try:
rows_q = db.query(DbSession).filter(DbSession.archived == False)
if user:
@@ -1349,15 +1339,11 @@ def setup_session_routes(
rows_q = rows_q.filter(DbSession.owner == user)
rows = rows_q.limit(2000).all()
folder_map = {r.id: r.folder for r in rows}
# Precompute per-session message counts in TWO aggregate queries
# Precompute per-session message counts in one aggregate query
# instead of 1–3 queries PER session — with many chats the per-row
# loop was doing thousands of round-trips and blowing the timeout.
from sqlalchemy import func as _sa_func
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
_asst_counts = dict(
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
)
cleanup_now = utcnow_naive()
for row in rows:
# Never delete important sessions
@@ -1367,9 +1353,10 @@ def setup_session_routes(
if (row.name or "").strip() == "Incognito":
should_delete = True
deleted_throwaway += 1
db.delete(row)
if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id)
else:
db.delete(row)
continue
if is_session_recently_active(row, now=cleanup_now):
continue
@@ -1378,31 +1365,11 @@ def setup_session_routes(
if msg_count == 0:
should_delete = True
deleted_empty += 1
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
name = (row.name or "").strip().lower()
# Check first user message content (AI renames sessions, so
# "hi" becomes "Casual Greeting Exchange" — name alone won't match)
first_msg = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id, DbMsg.role == "user"
).order_by(DbMsg.timestamp).first()
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
# Count assistant messages — if user sent something but AI never replied, it's dead
assistant_count = _asst_counts.get(row.id, 0)
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
should_delete = True
deleted_throwaway += 1
# Single user message with no AI response = dead session
elif msg_count == 1 and assistant_count == 0:
should_delete = True
deleted_throwaway += 1
# Short phrase (1-3 words) with no real AI conversation (<=2 msgs)
elif msg_count <= 2 and first_text and len(first_text.split()) <= 3 and len(first_text) <= 40:
should_delete = True
deleted_throwaway += 1
if should_delete:
db.delete(row)
if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id)
else:
db.delete(row)
if deleted_empty or deleted_throwaway:
db.commit()
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
+3 -9
View File
@@ -20,7 +20,7 @@ from src.task_action_policy import (
is_admin_only_task_action,
owner_has_admin_task_privileges,
)
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
from src.task_scheduler import compute_next_run, is_valid_cron, HOUSEKEEPING_DEFAULTS
from routes.prefs_routes import _load_for_user, _save_for_user
logger = logging.getLogger(__name__)
@@ -475,10 +475,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
raise HTTPException(400, "Cron expression is required for cron schedule")
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
try:
from croniter import croniter
croniter(req.cron_expression)
except Exception:
if not is_valid_cron(req.cron_expression):
raise HTTPException(400, "Invalid cron expression")
if req.trigger_type == "event" and not req.trigger_event:
raise HTTPException(400, "Event name is required for event-triggered tasks")
@@ -788,10 +785,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
task.character_id = req.character_id or None
if req.cron_expression is not None:
if req.cron_expression:
try:
from croniter import croniter
croniter(req.cron_expression)
except Exception:
if not is_valid_cron(req.cron_expression):
raise HTTPException(400, "Invalid cron expression")
task.cron_expression = req.cron_expression or None
+92 -46
View File
@@ -25,11 +25,12 @@ from src.auth_helpers import effective_user, is_delegated_credential
from src.owner_identity import auth_disabled, is_request_sentinel_owner
from src.attachment_refs import attachment_refs_from_metadata
from src.constants import GENERATED_IMAGES_DIR
from src.path_confinement import is_inside
from src.path_confinement import PathEscape, canonical_root, confine, is_inside
from src.upload_handler import (
UploadCleanupSafetyError,
count_recent_uploads,
extract_upload_ids,
secure_filename,
)
logger = logging.getLogger(__name__)
@@ -153,30 +154,47 @@ def setup_upload_routes(upload_handler):
def _upload_root() -> str:
from src.constants import UPLOAD_DIR
return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
def _path_inside_upload_dir(path: str) -> bool:
return is_inside(_upload_root(), path)
def _resolve_upload_path(file_id: str) -> str:
from src.constants import UPLOAD_DIR
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
direct = os.path.join(upload_root, file_id)
safe_id = secure_filename(os.path.basename(file_id))
if not safe_id:
raise HTTPException(400, "Invalid file ID")
root = _upload_root()
direct = os.path.normpath(os.path.join(root, safe_id))
if os.path.lexists(direct):
if not _path_inside_upload_dir(direct):
try:
canonical_direct = confine(root, direct, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if os.path.isfile(direct):
return direct
if os.path.isfile(canonical_direct):
return canonical_direct
raise HTTPException(404, "File not found")
for root, _dirs, files in os.walk(upload_root, followlinks=False):
if file_id not in files:
continue
path = os.path.join(root, file_id)
if not _path_inside_upload_dir(path):
info = _load_upload_info(safe_id)
stored_path = (info or {}).get("path")
if stored_path:
try:
canonical_stored = confine(root, stored_path, allow_root=False)
if os.path.isfile(canonical_stored):
return canonical_stored
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if os.path.isfile(path):
return path
for walk_root, _dirs, files in os.walk(root, followlinks=False):
if safe_id not in files:
continue
cand = os.path.normpath(os.path.join(walk_root, safe_id))
try:
canonical_cand = confine(root, cand, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if os.path.isfile(canonical_cand):
return canonical_cand
raise HTTPException(404, "File not found")
raise HTTPException(404, "File not found")
@@ -435,52 +453,79 @@ def setup_upload_routes(upload_handler):
logger.error(f"Failed to get upload stats: {e}")
raise HTTPException(500, "Failed to get upload statistics")
@router.get("/{file_id}")
@router.api_route("/{file_id}", methods=["GET", "HEAD"])
async def download_file(request: Request, file_id: str, thumb: int = 0):
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
JPEG thumbnail for images (used by chat attachment previews) so the
client isn't downloading the full-resolution photo just to show it tiny."""
"""Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns
a small cached JPEG thumbnail for images (used by chat attachment previews)
so the client isn't downloading the full-resolution photo just to show it tiny."""
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
safe_file_id = secure_filename(os.path.basename(file_id))
import mimetypes as _mt
# Look up original filename and owner from uploads.json
original_name = file_id
original_name = safe_file_id
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
# back to the .bak sibling, then to {}), so a truncated DB degrades to
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
db = upload_handler._load_upload_index()
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
info = next((fi for fi in db.values() if fi.get("id") == safe_file_id), None)
if info:
original_name = info.get("name", file_id)
original_name = info.get("name", safe_file_id)
file_owner = info.get("owner") if info else None
_verify_upload_access(request, file_owner)
path = _resolve_upload_path(file_id)
upload_root = _upload_root()
path = _resolve_upload_path(safe_file_id)
try:
path = confine(upload_root, path, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if not path.startswith(upload_root + os.sep) or not _path_inside_upload_dir(path):
raise HTTPException(403, "Access denied")
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
from fastapi.responses import FileResponse
# Downscaled thumbnail for image previews — generated once and cached.
# Skip generation during HEAD probe to avoid loading/processing whole image.
if thumb and mime.startswith("image/"):
thumb_dir = os.path.normpath(os.path.join(upload_root, ".thumbs"))
thumb_path = os.path.normpath(os.path.join(thumb_dir, safe_file_id + ".jpg"))
try:
from PIL import Image, ImageOps
thumb_dir = os.path.join(_upload_root(), ".thumbs")
os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, file_id + ".jpg")
if (not os.path.exists(thumb_path)
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
im = Image.open(path)
# iPhone / camera JPEGs encode rotation in EXIF rather than
# the pixel data. Browsers honour that on the original via
# image-orientation:from-image, but PIL strips EXIF when it
# saves the JPEG thumb, leaving the pixels sideways. Bake
# the rotation into the pixels before thumbnailing.
im = ImageOps.exif_transpose(im)
im.thumbnail((320, 320))
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
im.save(thumb_path, "JPEG", quality=80)
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
except Exception as e:
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
# Fall through to the full image.
thumb_path = confine(upload_root, thumb_path, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path):
raise HTTPException(403, "Access denied")
if request.method == "HEAD":
if (os.path.isfile(thumb_path)
and os.path.getmtime(thumb_path) >= os.path.getmtime(path)):
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
# GET may generate a JPEG or fall back to the original on failure.
# Neither representation is known without decoding the image. HEAD
# must not advertise the original/stale file's headers as a thumb.
from fastapi.responses import Response
response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS)
del response.headers["content-length"]
return response
else:
try:
from PIL import Image, ImageOps
os.makedirs(thumb_dir, exist_ok=True)
if (not os.path.exists(thumb_path)
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
im = Image.open(path)
# iPhone / camera JPEGs encode rotation in EXIF rather than
# the pixel data. Browsers honour that on the original via
# image-orientation:from-image, but PIL strips EXIF when it
# saves the JPEG thumb, leaving the pixels sideways. Bake
# the rotation into the pixels before thumbnailing.
im = ImageOps.exif_transpose(im)
im.thumbnail((320, 320))
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
im.save(thumb_path, "JPEG", quality=80)
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
except Exception as e:
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
# Fall through to the full image.
return FileResponse(
path,
media_type=mime,
@@ -496,9 +541,10 @@ def setup_upload_routes(upload_handler):
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
def _vision_cache_path(file_id: str) -> str:
cache_dir = os.path.join(_upload_root(), ".vision")
safe_id = secure_filename(os.path.basename(file_id))
cache_dir = os.path.normpath(os.path.join(_upload_root(), ".vision"))
os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, file_id + ".txt")
return os.path.normpath(os.path.join(cache_dir, safe_id + ".txt"))
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
"""Copy upload OCR/vision text onto the promoted gallery image row."""
+122
View File
@@ -0,0 +1,122 @@
"""Fail image builds that select an unfixed Debian native Brotli decoder.
Debian Node links libbrotlidec rather than bundling upstream Node's decoder.
Use Debian's backport revision, not BrotliDecoderVersion() or Python wheel
metadata, to establish the package floor for CVE-2026-111497:
https://security-tracker.debian.org/tracker/CVE-2026-111497
"""
import argparse
import ctypes
from pathlib import Path
import platform
import re
import subprocess
import sys
DEBIAN_FIXED = {"12": "1.0.9-2+deb12u1", "13": "1.1.0-2+deb13u1"}
DECODER_SONAME = "libbrotlidec.so.1"
def command(*args):
return subprocess.check_output(args, text=True).strip()
def check_package():
release = platform.freedesktop_os_release()
floor = DEBIAN_FIXED.get(release.get("VERSION_ID"))
if release.get("ID") != "debian" or floor is None:
raise ValueError("Brotli package floor is not defined for this distribution")
record = command(
"dpkg-query", "-W", "-f=${Status}\n${Version}", "libbrotli1"
).splitlines()
if len(record) != 2 or any(not value.strip() for value in record):
raise ValueError("invalid libbrotli1 package record: expected status and version")
status, version = record
if status != "install ok installed":
raise ValueError(f"libbrotli1 is not fully installed (status: {status})")
if subprocess.run(
["dpkg", "--compare-versions", version, "ge", floor], check=False
).returncode != 0:
raise ValueError(f"libbrotli1 {version} does not meet Debian's fixed floor {floor}")
owned = {
Path(line).resolve()
for line in command("dpkg-query", "-L", "libbrotli1").splitlines()
if Path(line).name == DECODER_SONAME
}
if not owned:
raise ValueError("libbrotli1 has no packaged native decoder")
return version, owned
def loaded_path(library):
# Inspect the library actually selected by the dynamic loader. An installed
# fixed package alone would not catch LD_LIBRARY_PATH or /usr/local shadows.
class DlInfo(ctypes.Structure):
_fields_ = [("filename", ctypes.c_char_p), ("base", ctypes.c_void_p),
("symbol", ctypes.c_char_p), ("address", ctypes.c_void_p)]
dladdr = ctypes.CDLL(None).dladdr
dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(DlInfo)]
dladdr.restype = ctypes.c_int
info = DlInfo()
if not dladdr(ctypes.cast(library.BrotliDecoderDecompress, ctypes.c_void_p), ctypes.byref(info)):
raise ValueError("cannot identify the loaded Brotli decoder")
return Path(info.filename.decode()).resolve()
def check_decoder(library):
# The fix adds error -17. Older decoders return INVALID here, including
# upstream 1.1.0 and 1.2.0. Fixed Debian 12 and 13 backports expose the same
# marker with different error-string prefixes.
# No malformed attacker-controlled stream is needed for this build check.
library.BrotliDecoderErrorString.argtypes = [ctypes.c_int]
library.BrotliDecoderErrorString.restype = ctypes.c_char_p
if library.BrotliDecoderErrorString(-17) not in (b"BLOCK_SWITCH", b"_ERROR_FORMAT_BLOCK_SWITCH"):
raise ValueError("native decoder lacks the CVE-2026-111497 BLOCK_SWITCH fix marker")
library.BrotliDecoderDecompress.argtypes = [
ctypes.c_size_t, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p
]
library.BrotliDecoderDecompress.restype = ctypes.c_int
# A small, valid uncompressed Brotli stream must still decode correctly.
encoded = bytes.fromhex("0b058068656c6c6f20776f726c6403")
output = ctypes.create_string_buffer(11)
size = ctypes.c_size_t(len(output))
result = library.BrotliDecoderDecompress(len(encoded), encoded, ctypes.byref(size), output)
if result != 1 or size.value != 11 or output.raw != b"hello world":
raise ValueError("native Brotli decoder failed the valid-stream control")
def check_node(node, decoder):
# ldd inspects the distro executable and its libnode dependency without
# executing JavaScript or relying on process.versions.brotli.
linked = command("ldd", str(node))
match = re.search(r"libbrotlidec\.so\.1\s+=>\s+(/\S+)", linked)
if not match or Path(match[1]).resolve() != decoder:
raise ValueError(f"{node} does not select the checked Debian Brotli decoder")
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--node", type=Path, help="also verify this distro Node executable's decoder")
args = parser.parse_args(argv)
try:
version, owned = check_package()
library = ctypes.CDLL(DECODER_SONAME)
decoder = loaded_path(library)
if decoder not in owned:
raise ValueError(f"selected decoder is outside libbrotli1: {decoder}")
check_decoder(library)
if args.node:
check_node(args.node, decoder)
except (ValueError, OSError, AttributeError, subprocess.CalledProcessError) as exc:
print(f"Brotli acceptance failed: {exc}", file=sys.stderr)
return 1
print(f"Brotli accepted: Debian libbrotli1 {version}, decoder {decoder}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9 -2
View File
@@ -6,6 +6,7 @@ that are awkward at the shell). Filters images by tag, prompt, favorite,
album, and surfaces EXIF metadata.
odysseus-gallery list --limit 20 --favorites
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
odysseus-gallery show IMAGE_ID
odysseus-gallery albums | jq -r '.[] | .name'
odysseus-gallery search "sunset" # matches prompt + tags
@@ -83,7 +84,13 @@ def cmd_list(args):
fail(f"no album named {args.album!r}")
q = q.filter(GalleryImage.album_id == al.id)
if args.tag:
q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
for tag in (value.strip() for value in args.tag.split(",")):
if not tag:
continue
q = q.filter(
GalleryImage.tags.ilike(f"%{tag}%") |
GalleryImage.ai_tags.ilike(f"%{tag}%")
)
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
emit([_serialize_image(i) for i in q.all()], args)
finally:
@@ -159,7 +166,7 @@ def _build_parser():
pl.add_argument("--limit", type=int, default=50)
pl.add_argument("--favorites", action="store_true")
pl.add_argument("--album", help="filter by album name")
pl.add_argument("--tag", help="substring match against tags column")
pl.add_argument("--tag", help="comma-separated tags; each matches manual or AI tags by substring")
pl.set_defaults(func=cmd_list)
psh = sub.add_parser("show", parents=[common])
+12 -6
View File
@@ -43,7 +43,7 @@ import logging
import os
import re
import sys
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
# Anchor the path so the script works when invoked from anywhere.
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
emit([], args)
return
all_uids = data[0].split()
# Newest first, limit
# Keep a bounded arrival batch; order its decoded rows by Date below.
uids = list(reversed(all_uids))[: args.limit]
out = []
for uid in uids:
@@ -165,10 +165,15 @@ def cmd_list(args) -> None:
sender_name, sender_addr = parseaddr(sender)
date_raw = msg.get("Date", "")
try:
iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
parsed_date = parsedate_to_datetime(date_raw) if date_raw else None
iso = parsed_date.isoformat() if parsed_date else ""
if parsed_date is not None and parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
date_epoch = parsed_date.timestamp() if parsed_date else float("-inf")
except Exception:
iso = ""
out.append({
date_epoch = float("-inf")
out.append((date_epoch, {
"uid": uid.decode(),
"date": iso,
"from_addr": sender_addr,
@@ -176,10 +181,11 @@ def cmd_list(args) -> None:
"subject": subject,
"is_read": "\\Seen" in flags,
"is_answered": "\\Answered" in flags,
})
}))
except Exception as e:
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
emit(out, args)
out.sort(key=lambda item: item[0], reverse=True)
emit([row for _, row in out], args)
# ─── read ────────────────────────────────────────────────────────────
+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""Sanitize Playwright trace archives to strip sensitive credentials and tokens.
Structured, format-aware fail-closed security:
Replaces naive regex-based replacement with structured JSON / HAR parsing,
recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization,
safe source code masking, and strict fail-closed purge on corruption or error.
"""
from __future__ import annotations
import json
import logging
import os
import re
import sys
import urllib.parse
import zipfile
from typing import Any, Dict, List, Set
logger = logging.getLogger(__name__)
BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico')
SENSITIVE_KEY_PATTERN = re.compile(
r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)'
)
SENSITIVE_PARAM_NAMES = frozenset({
"token", "session", "auth", "password", "passwd", "key", "secret", "api_key",
"apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig",
"signature", "client_secret", "credential"
})
SENSITIVE_HEADER_NAMES = frozenset({
"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key",
"api-key", "x-auth-token", "x-access-token"
})
def sanitize_url(url: str) -> str:
"""Sanitize query parameters and authority credentials in URLs."""
if not isinstance(url, str) or not url.strip():
return url
try:
parts = urllib.parse.urlsplit(url)
except Exception:
return url
netloc = parts.netloc
if "@" in netloc:
userinfo, host = netloc.split("@", 1)
if ":" in userinfo:
user, _ = userinfo.split(":", 1)
netloc = f"{user}:[REDACTED]@{host}"
else:
netloc = f"[REDACTED]@{host}"
query = parts.query
if query:
q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
sanitized_pairs = []
for k, v in q_pairs:
k_lower = k.lower().replace("-", "_")
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
sanitized_pairs.append((k, "[REDACTED]"))
else:
sanitized_pairs.append((k, v))
query = urllib.parse.urlencode(sanitized_pairs)
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))
def sanitize_body_text(text: str) -> str:
"""Sanitize request bodies formatted as JSON or URL-encoded form data."""
if not isinstance(text, str) or not text.strip():
return text
# 1. Try JSON
trimmed = text.strip()
if trimmed.startswith(("{", "[")):
try:
parsed = json.loads(trimmed)
sanitized = sanitize_structured_obj(parsed)
return json.dumps(sanitized, ensure_ascii=False)
except Exception as exc:
if SENSITIVE_KEY_PATTERN.search(trimmed) or any(p in trimmed.lower() for p in SENSITIVE_PARAM_NAMES):
raise ValueError(f"Malformed sensitive JSON request body: {exc}") from exc
# 2. Try URL-encoded form data
if "=" in trimmed and not trimmed.startswith("<"):
try:
pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True)
if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs):
out_pairs = []
for k, v in pairs:
k_lower = k.lower().replace("-", "_")
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
out_pairs.append((k, "[REDACTED]"))
else:
out_pairs.append((k, v))
return urllib.parse.urlencode(out_pairs)
except Exception as exc:
if SENSITIVE_KEY_PATTERN.search(trimmed):
raise ValueError(f"Malformed sensitive form request body: {exc}") from exc
# If unparsed body text contains sensitive keys or tokens, fail closed
if SENSITIVE_KEY_PATTERN.search(trimmed) or any(p in trimmed.lower() for p in SENSITIVE_PARAM_NAMES):
raise ValueError("Malformed or unsupported sensitive request body in trace entry")
return text
def sanitize_structured_obj(obj: Any) -> Any:
"""Recursively sanitize Python objects decoded from JSON or HAR payloads."""
if isinstance(obj, dict):
new_dict: Dict[str, Any] = {}
# Check for header entry with inverted or standard keys: {"name": ..., "value": ...}
name_val = obj.get("name")
val_val = obj.get("value")
if isinstance(name_val, str) and isinstance(val_val, str):
name_lower = name_val.lower().strip()
if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower):
for k, v in obj.items():
if k == "value":
new_dict[k] = "[REDACTED]"
else:
new_dict[k] = sanitize_structured_obj(v)
return new_dict
# Process standard dictionary keys
for k, v in obj.items():
k_lower = str(k).lower().replace("-", "_")
# Action parameters in Playwright traces (e.g. page.fill, page.type)
if k == "params" and isinstance(v, dict):
sanitized_params = sanitize_structured_obj(v)
# If this action involves fill, type, or sensitive inputs, redact value/text
if "value" in sanitized_params:
sanitized_params["value"] = "[REDACTED]"
if "text" in sanitized_params:
sanitized_params["text"] = "[REDACTED]"
new_dict[k] = sanitized_params
continue
# DOM snapshot nodes
if k == "attributes" and isinstance(v, (dict, list)):
if isinstance(v, dict):
attr_dict = dict(v)
type_val = str(attr_dict.get("type") or "").lower()
if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower():
if "value" in attr_dict:
attr_dict["value"] = "[REDACTED]"
new_dict[k] = attr_dict
continue
elif isinstance(v, list):
# List of attribute strings or pairs
new_list = []
is_pw = any("password" in str(x).lower() for x in v)
for x in v:
if is_pw and isinstance(x, str) and not x.startswith("type="):
new_list.append("[REDACTED]")
else:
new_list.append(sanitize_structured_obj(x))
new_dict[k] = new_list
continue
# Request postData / text
if k in ("postData", "postDataText") and isinstance(v, str):
new_dict[k] = sanitize_body_text(v)
continue
# URL fields
if k == "url" and isinstance(v, str):
new_dict[k] = sanitize_url(v)
continue
# Cookie arrays
if k == "cookies" and isinstance(v, list):
sanitized_cookies = []
for c in v:
if isinstance(c, dict):
c_copy = dict(c)
if "value" in c_copy:
c_copy["value"] = "[REDACTED]"
sanitized_cookies.append(c_copy)
else:
sanitized_cookies.append(sanitize_structured_obj(c))
new_dict[k] = sanitized_cookies
continue
# Direct sensitive key matches
if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str):
new_dict[k] = "[REDACTED]"
continue
new_dict[k] = sanitize_structured_obj(v)
return new_dict
elif isinstance(obj, list):
return [sanitize_structured_obj(item) for item in obj]
elif isinstance(obj, str):
# Check for inline credentials/tokens
if obj.startswith("Bearer ") or obj.startswith("bearer "):
return "Bearer [REDACTED]"
if obj.startswith("Basic ") or obj.startswith("basic "):
return "Basic [REDACTED]"
return obj
return obj
def sanitize_jsonl_content(data: bytes) -> bytes:
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks).
If any non-empty record cannot be decoded as valid UTF-8 or parsed as valid JSON,
or is malformed, raise ValueError fail-closed so the affected archive is permanently removed.
"""
lines = data.split(b"\n")
out_lines = []
for line in lines:
if not line.strip():
out_lines.append(line)
continue
try:
parsed = json.loads(line.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as err:
raise ValueError(f"Malformed or unsupported JSONL trace record: {err}")
sanitized = sanitize_structured_obj(parsed)
out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
return b"\n".join(out_lines)
def sanitize_trace_archive(zp: str) -> bool:
"""Sanitize a single zip archive in a strict fail-closed manner.
If the archive is malformed, partial, unreadable, or encounters an exception
during processing, the archive is permanently deleted so that raw traces
cannot leak.
Returns True if successfully sanitized, False if deleted due to error.
"""
if not os.path.exists(zp):
return False
temp_zp = zp + ".tmp"
try:
if os.path.getsize(zp) == 0:
raise zipfile.BadZipFile("Empty archive")
with zipfile.ZipFile(zp, "r") as zin:
infolist = zin.infolist()
if not infolist:
raise zipfile.BadZipFile("Archive has no entries")
corrupt_file = zin.testzip()
if corrupt_file is not None:
raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}")
with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for item in infolist:
filename_lower = item.filename.lower()
# 1. Binary image screenshots/diagnostics: preserve as-is
if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS):
content = zin.read(item.filename)
zout.writestr(item, content)
continue
# 2. Embedded test source code (e.g. resources/src@... or .js/.ts):
# Mask safely without syntax corruption!
if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")):
safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n"
zout.writestr(item, safe_placeholder)
continue
# 3. JSON Lines and structured JSON/HAR traces
content = zin.read(item.filename)
if filename_lower.endswith((".trace", ".network", ".stacks")):
sanitized_content = sanitize_jsonl_content(content)
zout.writestr(item, sanitized_content)
elif filename_lower.endswith((".json", ".har")):
try:
parsed = json.loads(content.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise ValueError(f"Malformed JSON in archive member {item.filename}: {exc}") from exc
sanitized = sanitize_structured_obj(parsed)
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
else:
# Unknown binary or text member: exclude to fail closed
continue
# Atomic replace
os.replace(temp_zp, zp)
return True
except Exception as exc:
logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc)
for path_to_remove in (temp_zp, zp):
if os.path.exists(path_to_remove):
try:
os.remove(path_to_remove)
except OSError as remove_err:
logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err)
return False
def sanitize_directory(results_dir: str) -> int:
"""Walk results_dir and sanitize every .zip archive fail-closed.
Returns the number of archives processed. Exits with 1 if any archive fails.
"""
if not os.path.exists(results_dir):
return 0
count = 0
all_ok = True
for root, _, files in os.walk(results_dir):
for f in files:
if f.endswith(".zip"):
zp = os.path.join(root, f)
ok = sanitize_trace_archive(zp)
if not ok:
all_ok = False
count += 1
if not all_ok:
sys.exit(1)
return count
def main() -> None:
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <results_directory>")
sys.exit(1)
target_dir = sys.argv[1]
sanitize_directory(target_dir)
if __name__ == "__main__":
main()
+36 -6
View File
@@ -11,6 +11,38 @@ import time
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _stop_owned_server(server, identity):
"""Close only identity-bound descendants, including MCP's new sessions.
Capture before signalling the parent: an orphaned stderr holder can no
longer be attributed by ancestry. Never search by executable name or kill
the caller's process group.
"""
from src import process_lifecycle as lifecycle
descendants = (lifecycle.bind_descendants([identity.pid])
if identity.verdict() == lifecycle.OWNED else [])
if os.name == "nt":
if identity.verdict() == lifecycle.OWNED:
lifecycle.taskkill_tree(identity.pid)
else:
outcome = lifecycle.terminate_identities(
[*(entry.identity for entry in reversed(descendants)), identity],
steps=lifecycle.term_kill_steps(10),
)
if outcome.survivors or outcome.unverified:
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
try:
logs, _ = server.communicate(timeout=2)
except subprocess.TimeoutExpired as error:
# Do not turn an unidentified pipe holder into an unbounded wait.
server.stdout.close()
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
return logs
def main():
@@ -36,7 +68,10 @@ def main():
server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
start_new_session=(os.name != "nt"),
)
from src.process_lifecycle import ProcessIdentity
server_identity = ProcessIdentity.capture(server.pid)
result = None
try:
origin = f"http://127.0.0.1:{port}"
@@ -62,12 +97,7 @@ def main():
print(result.stderr, file=sys.stderr)
return result.returncode
finally:
server.terminate()
try:
logs, _ = server.communicate(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
logs, _ = server.communicate()
logs = _stop_owned_server(server, server_identity)
if result is None or result.returncode:
print(logs[-8000:], file=sys.stderr)
+20 -2
View File
@@ -202,17 +202,35 @@ def _infer_use_case(repo_id, pipeline_tag):
return "general"
# HF collections mix in models no Cookbook LLM backend can serve (vision
# encoders, time-series forecasters, image/video generators). Image models
# have their own registry, so keep these out of the LLM catalog.
_NON_LLM_PIPELINE_TAGS = frozenset({
"time-series-forecasting",
"zero-shot-image-classification",
"image-classification",
"image-feature-extraction",
"text-to-image",
"image-to-video",
"text-to-video",
})
def _entry_from_collection_item(collection, item, source):
repo_id = item.get("id") or ""
if item.get("type") != "model" or not repo_id:
return None
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
return None
repo_prefix = source.get("repo_prefix")
if repo_prefix and not repo_id.startswith(repo_prefix):
return None
raw_params = item.get("numParameters") or 0
active = None
# Read the name even when HF gives a total: it is the only source of the
# MoE active size (e.g. "-35B-A3B"), which the speed estimate depends on.
name_params, active = _parse_params_from_name(repo_id)
if not raw_params:
raw_params, active = _parse_params_from_name(repo_id)
raw_params = name_params
param_label, raw_params = _format_params(raw_params)
if not raw_params:
return None
+6 -2
View File
@@ -84,10 +84,14 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE)
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
def _parse_scalar(raw: str) -> Any:
def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
raw = raw.strip()
if raw == "":
return ""
# String fields keep their spelling; quoted values still use the decoder
# below so existing escapes round-trip as before.
if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')):
return raw
if raw.startswith("[") and raw.endswith("]"):
inner = raw[1:-1].strip()
if not inner:
@@ -171,7 +175,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
pending_key = key
fm[key] = []
else:
fm[key] = _parse_scalar(val)
fm[key] = _parse_scalar(val, string_only=(key == "version"))
pending_key = None
continue
m2 = _FM_BLOCK_LIST_RE.match(line)
+2 -2
View File
@@ -2834,7 +2834,7 @@ def _parse_qwen_explicit_admin_request(text: str) -> Optional[tuple[str, str]]:
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
return "manage_settings", json.dumps({"action": "list_tools"})
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
return "manage_settings", json.dumps({"action": "enable_tool", "tool": "images"})
return "manage_settings", json.dumps({"action": "list_tools"})
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
@@ -8057,7 +8057,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g.
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool|enable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`.",
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`. You cannot re-enable a disabled tool: that is done by the user in Settings > Agent Tools.",
"manage_notes": """\
```manage_notes
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
+35 -13
View File
@@ -585,7 +585,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
return _ALIASES_SET.get(k2, (k or "").strip())
def _is_managed_key(key):
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
# Tool policy is not a general setting, even if a future default
# is added: resetting it must never re-enable disabled tools.
return key != "disabled_tools" and key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
_ENUMS = {
"image_quality": ["low", "medium", "high"],
@@ -713,13 +715,35 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
save_settings(s)
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
elif action in ("disable_tool", "enable_tool", "list_tools"):
elif action == "enable_tool":
# The global disabled_tools denylist is the one hard permission
# boundary the Settings -> Agent Tools panel offers admins. It
# must only ever be *narrowed* from inside an agent turn: a
# model that can call enable_tool can undo the boundary itself,
# and any web page, email, or document the agent reads can ask
# it to (prompt injection re-enabling bash/email/etc.). Widening
# the tool set stays a human action in the Settings panel.
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
return {
"error": (
f"Re-enabling tools is not available from chat"
f"{f' (requested: {tool_name})' if tool_name else ''}. "
"Disabled tools are an admin policy: ask the user to turn the tool "
"back on in Settings > Agent Tools. You can still disable tools "
"(disable_tool) or list the current state (list_tools)."
),
"exit_code": 1,
}
elif action in ("disable_tool", "list_tools"):
# Tool-toggle actions. These edit settings.json:disabled_tools
# (the global list read on every chat request) rather than
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
# "search" -> "web_search", "browser" -> browser tools,
# "documents" -> the document tool set, "memory" ->
# manage_memory, etc.
# manage_memory, etc. Only disable_tool mutates: see the
# enable_tool branch above for why the agent can never widen
# the denylist.
from src.settings import get_setting, save_settings, load_settings
_ALIASES = {
"shell": ["bash"],
@@ -753,7 +777,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
"response": (
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
"Common toggles: shell (bash), search (web_search), browser, documents, "
"memory, skills, images, tasks, notes, calendar, email."
"memory, skills, images, tasks, notes, calendar, email. "
"To re-enable a tool, open Settings > Agent Tools."
),
"disabled": list(current),
"exit_code": 0,
@@ -767,22 +792,19 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
settings = load_settings()
current = list(settings.get("disabled_tools") or [])
before = set(current)
if action == "disable_tool":
for t in targets:
if t not in current:
current.append(t)
else: # enable_tool
current = [t for t in current if t not in targets]
for t in targets:
if t not in current:
current.append(t)
after = set(current)
settings["disabled_tools"] = current
save_settings(settings)
verb = "Disabled" if action == "disable_tool" else "Enabled"
changed = sorted(after.symmetric_difference(before))
return {
"response": (
f"{verb} {tool_name} ({', '.join(targets)}). "
f"Now disabled: {', '.join(current) if current else '(none)'}."
f"Disabled {tool_name} ({', '.join(targets)}). "
f"Now disabled: {', '.join(current) if current else '(none)'}. "
"Re-enabling is done by the user in Settings > Agent Tools."
),
"changed": changed,
"disabled": list(current),
+8 -8
View File
@@ -1207,7 +1207,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
Line 1: prompt describing the image
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
Line 3: size (optional, defaults to 1024x1024)
Line 4: quality (optional, defaults to medium — options: low, medium, high, auto)
Line 4: quality (optional, defaults to configured quality or medium — low, medium, high, auto)
"""
import base64
import httpx
@@ -1225,13 +1225,13 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
prompt = str(args.get('prompt') or '').strip()
model_spec = str(args.get('model') or '').strip()
size = str(args.get('size') or '1024x1024')
quality = str(args.get('quality') or 'medium')
quality = str(args.get('quality') or '')
else:
lines = content.strip().split("\n")
prompt = lines[0].strip() if lines else ""
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else ""
if not prompt:
return {"error": "Image prompt is required (line 1)"}
@@ -1249,8 +1249,8 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
# Use admin-configured model/quality if not specified by the tool call
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"]
if not quality:
quality = _settings.get("image_quality") or "medium"
# Auto-detect best available image model if still not set
if not model_spec:
@@ -1482,7 +1482,7 @@ async def do_edit_image(
session_id: Optional[str] = None,
owner: Optional[str] = None,
size: str = "auto",
quality: str = "medium",
quality: Optional[str] = None,
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
incognito: bool = False,
) -> Dict:
@@ -1509,8 +1509,8 @@ async def do_edit_image(
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"]
if quality is None:
quality = _settings.get("image_quality") or "medium"
if not model_spec:
return {"error": "No image model selected for image editing"}
+239 -10
View File
@@ -339,6 +339,229 @@ async def self_check(
return out
def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]:
"""Resolve an explicit agent_model override under server authority.
Guarantees:
- Client-provided raw URLs never determine arbitrary outbound network destinations.
- Registered endpoint IDs resolve only to enabled endpoints visible to the owner.
- Shared endpoints (owner IS NULL) remain visible to authorized users.
- Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None).
- Admin privileges do not allow arbitrary unconfigured destinations.
"""
raw = str(override or "").strip()
if not raw:
return None
if "::" in raw:
target, requested_model = raw.split("::", 1)
target = target.strip()
requested_model = requested_model.strip()
else:
target = ""
requested_model = raw
# 1. Explicit local selection or matching a configured local base
if target == "local":
bases = local_bases()
if not bases:
return None
return {
"base": bases[0],
"model": requested_model or local_model(),
"headers": None,
"source": "override",
}
for lb in local_bases():
if target and target.rstrip("/") == lb.rstrip("/"):
return {
"base": lb,
"model": requested_model or local_model(),
"headers": None,
"source": "override",
}
# 2. Explicit hosted fallback selection
try:
from src.endpoint_resolver import resolve_endpoint
for prefix in ("utility", "default"):
try:
url, model, headers = resolve_endpoint(prefix, owner=owner)
except Exception:
continue
if url and model:
if target in ("default", "hosted"):
return {
"base": url,
"model": requested_model or model,
"headers": headers,
"source": "hosted",
}
if target and target.rstrip("/") == url.rstrip("/"):
if not requested_model or requested_model == model:
return {
"base": url,
"model": requested_model or model,
"headers": headers,
"source": "hosted",
}
except Exception:
pass
if target in ("default", "hosted"):
return None
# 3. Bare model id without endpoint prefix
if not target:
if requested_model == "local":
bases = local_bases()
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None
if requested_model in ("default", "hosted"):
return _resolve_override_candidate("default::", owner)
# Exact match with local model
if requested_model == local_model():
bases = local_bases()
if bases:
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"}
# Exact match with hosted default/utility
try:
from src.endpoint_resolver import resolve_endpoint
for prefix in ("utility", "default"):
try:
url, model, headers = resolve_endpoint(prefix, owner=owner)
except Exception:
continue
if url and model and model == requested_model:
return {"base": url, "model": model, "headers": headers, "source": "hosted"}
except Exception:
pass
# Check registered database endpoints for this bare model
try:
from core.database import SessionLocal, ModelEndpoint
from src.auth_helpers import owner_filter, _auth_disabled
from src.endpoint_resolver import (
resolve_endpoint_runtime,
build_chat_url,
build_headers,
_endpoint_enabled_models,
_endpoint_hidden_models,
)
db = SessionLocal()
try:
if not owner and not _auth_disabled():
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True,
ModelEndpoint.owner == None,
)
else:
q = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
ModelEndpoint,
owner,
)
matching_eps = []
for ep in q.all():
enabled_models = _endpoint_enabled_models(ep)
hidden_models = _endpoint_hidden_models(ep)
if requested_model in enabled_models and requested_model not in hidden_models:
matching_eps.append(ep)
# Avoid model ID collision across providers: only accept if unique
if len(matching_eps) == 1:
ep = matching_eps[0]
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
return {
"base": build_chat_url(base),
"model": requested_model,
"headers": build_headers(api_key, base),
"source": "registered",
"endpoint_id": ep.id,
}
finally:
db.close()
except Exception:
pass
return None
# 4. Target is an endpoint_id or a registered legacy endpoint URL
try:
from core.database import SessionLocal, ModelEndpoint
from src.auth_helpers import owner_filter, _auth_disabled
from src.endpoint_resolver import (
resolve_endpoint_runtime,
build_chat_url,
build_headers,
_endpoint_enabled_models,
_endpoint_hidden_models,
_first_chat_model,
)
db = SessionLocal()
try:
if not owner and not _auth_disabled():
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True,
ModelEndpoint.owner == None,
)
else:
q = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
ModelEndpoint,
owner,
)
# Lookup by ID
ep = q.filter(ModelEndpoint.id == target).first()
# If not found by ID, match legacy registered base URL (strictly registered rows only)
if ep is None and ("://" in target or target.startswith("http")):
target_norm = target.rstrip("/")
for cand_ep in q.all():
cand_base = (cand_ep.base_url or "").rstrip("/")
if (
cand_base == target_norm
or cand_base + "/v1" == target_norm
or cand_base == target_norm + "/v1"
):
ep = cand_ep
break
if ep is None:
# Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed!
return None
enabled_models = _endpoint_enabled_models(ep)
hidden_models = _endpoint_hidden_models(ep)
if requested_model:
if requested_model in hidden_models:
return None
if enabled_models and requested_model not in enabled_models:
# Model not served by this endpoint -> fail closed against forged model IDs
return None
model = requested_model
else:
model = _first_chat_model(enabled_models)
if not model:
return None
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
return {
"base": build_chat_url(base),
"model": model,
"headers": build_headers(api_key, base),
"source": "registered",
"endpoint_id": ep.id,
}
finally:
db.close()
except Exception:
return None
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
cands: List[Dict[str, Any]] = []
for base in local_bases():
@@ -357,16 +580,13 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
except Exception:
pass
if override:
base, model = _split_override(override)
# A request may choose a configured endpoint, never introduce a URL.
# Prefer an exact model match (including hosted credentials) over the
# first local endpoint when a bare model id is supplied.
matching = [c for c in cands if not base or str(c["base"]).rstrip("/") == base.rstrip("/")]
selected = next((c for c in matching if c["model"] == model), None)
if selected is None and model and matching and matching[0]["source"] == "local":
selected = {**matching[0], "model": model}
if selected is not None:
cands.insert(0, {**selected, "source": "override"})
cand = _resolve_override_candidate(override, owner)
if cand is not None:
cands.insert(0, cand)
return cands
# Unauthorized, invalid, foreign, disabled, or forged override:
# Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback.
return []
return cands
@@ -438,9 +658,11 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
"source": "local",
"vision": True,
"base_url": base,
"endpoint_id": "local",
})
try:
from src.endpoint_resolver import resolve_endpoint
from src.settings import get_user_setting, load_settings
for prefix in ("utility", "default"):
try:
@@ -451,12 +673,19 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
key = ("hosted", model)
if key not in seen:
seen.add(key)
ep_id = None
try:
settings = load_settings()
ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None
except Exception:
pass
models.append({
"id": model,
"label": model,
"source": "hosted",
"vision": None,
"base_url": url,
"endpoint_id": ep_id or prefix,
})
break
except Exception:
+20 -6
View File
@@ -432,12 +432,15 @@ class _DegenerateStreamGuard:
"Summer Summer ..."). This is not a useful response and can burn context,
browser memory, and GPU time. Keep the guard conservative: only fire on long
same-token runs or a very dominant repeated token in the recent window.
Repetition made only of numbers is data (a zero or identity matrix, a
padded buffer), so it only stops at a long all-numeric backstop.
"""
def __init__(self, model: str):
self.model = model or "model"
self.last_token = ""
self.same_run = 0
self.numeric_run = 0
self.recent_tokens: List[str] = []
self.periodic_tokens: List[str] = []
self.total_chars = 0
@@ -458,6 +461,7 @@ class _DegenerateStreamGuard:
else:
self.last_token = token
self.same_run = 1
self.numeric_run = self.numeric_run + 1 if token.isdigit() else 0
self.recent_tokens.append(token)
self.periodic_tokens.append(token)
if len(self.recent_tokens) > 96:
@@ -466,14 +470,20 @@ class _DegenerateStreamGuard:
self.periodic_tokens = self.periodic_tokens[-2048:]
reason = None
if self.same_run >= 28 and self.total_chars >= 100:
if self.same_run >= 28 and self.total_chars >= 100 and not self.last_token.isdigit():
reason = f"repeated '{self.last_token}' {self.same_run} times"
elif len(self.recent_tokens) >= 72:
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
count = self.recent_tokens.count(top)
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not top.isdigit():
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
if not reason and len(self.recent_tokens) >= 48:
if (
not reason
and len(self.recent_tokens) >= 48
# A window that is mostly numbers is matrix/table data; its
# periodicity is real. Mixed loops still hit the exact-block check.
and sum(t.isdigit() for t in self.recent_tokens) * 2 < len(self.recent_tokens)
):
# Detect a periodic suffix instead of counting repeated n-grams.
# Reused property names are normal in CSS/SVG/JSON; a collapsed
# generation repeats nearly every token at a short fixed period.
@@ -510,10 +520,13 @@ class _DegenerateStreamGuard:
period = current_anchor - previous
if period < 24 or len(history) < period * 2:
continue
if history[-2 * period:-period] == history[-period:]:
if history[-2 * period:-period] == history[-period:] and not all(t.isdigit() for t in history[-period:]):
reason = f"repeated an exact {period}-token block twice"
break
if not reason and self.numeric_run >= 2048:
reason = f"emitted {self.numeric_run} numbers in a row"
if not reason:
return None
@@ -1556,8 +1569,9 @@ def _build_chatgpt_responses_payload(
"stream": stream,
"store": False,
}
if not _restricts_temperature(model):
payload["temperature"] = temperature
# ChatGPT plan Responses requests reject temperature for every model.
# The common caller still passes its preset value, but this provider must
# omit the field even for model names outside the known reasoning families.
# ChatGPT Subscription Codex API does not support max_output_tokens —
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
# Do not include it in the payload.
+15 -5
View File
@@ -10,6 +10,7 @@ import logging
import os
import re
import asyncio
import contextlib
from typing import Any, Dict, List, Optional, Set, Tuple
from src.database import McpServer, SessionLocal
@@ -383,12 +384,21 @@ class McpManager:
provider = build_provider(server_id, url, on_redirect=_on_redirect)
stack = AsyncExitStack()
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
read_stream, write_stream, _get_session_id = transport
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize()
try:
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
read_stream, write_stream, _get_session_id = transport
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize()
tools_result = await session.list_tools()
tools_result = await session.list_tools()
except BaseException:
# Close the half-open transport here, in the task that entered
# it. Left for the garbage collector, its AnyIO cancel scope is
# exited from the wrong task and the event loop keeps spinning
# (#5518). Its own teardown error would only mask the real one.
with contextlib.suppress(Exception):
await stack.aclose()
raise
tools = []
for tool in tools_result.tools:
tools.append({
+4 -1
View File
@@ -193,7 +193,10 @@ class ModelDiscovery:
data = r.json()
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
ids = [
m["id"] for m in items
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]
]
if ids:
return {
"host": host,
+4 -2
View File
@@ -102,8 +102,10 @@ _PLAIN_FRONT_MATTER_RE = re.compile(
# - **label:** value <!-- field=NAME-ENC type=text -->
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
# Accept Unicode word characters for older saved markers, whose encoder left
# non-ASCII letters/numbers literal. Newly rendered names are ASCII-safe.
_FIELD_BULLET_RE = re.compile(
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[A-Za-z0-9_.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[\w.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
)
@@ -115,7 +117,7 @@ def _encode_name(name: str) -> str:
"""
out = []
for ch in name or "":
if ch.isalnum() or ch in ("_", ".", "-"):
if (ch.isascii() and ch.isalnum()) or ch in ("_", ".", "-"):
out.append(ch)
else:
for b in ch.encode("utf-8"):
+8 -51
View File
@@ -12,16 +12,6 @@ from datetime import datetime, timedelta, timezone
logger = logging.getLogger(__name__)
# Names that indicate a throwaway/test session
_THROWAWAY_NAMES = {
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
"yo", "sup", "hola", "hii", "hiii", "heyo",
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
"new chat", "delete", "remove", "junk", "trash", "xxx",
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
"ok", "lol", "bruh", "hmm", "hm", "meh",
}
_THROWAWAY_MAX_MESSAGES = 4
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
@@ -54,14 +44,15 @@ def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> boo
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
"""Run session cleanup + (optional) AI folder sort for the given owner.
"""Remove empty/Incognito sessions and optionally organize saved chats.
Args:
owner: user whose sessions to process
skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
skip_llm: when True, do only Phase 1 (delete empty/Incognito sessions);
skip Phase 2 (AI folder assignment). Used by the built-in daily
background sweep so it never burns LLM tokens.
delete_throwaway: when False, only empty/incognito sessions are deleted.
delete_throwaway: retained for caller compatibility. Ordinary sessions
with saved messages are always preserved, regardless of this flag.
Returns a human-readable summary of what was done.
"""
@@ -71,7 +62,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
db = SessionLocal()
try:
# ── Phase 1: Delete empty/throwaway sessions ──
# ── Phase 1: Delete only empty and explicitly Incognito sessions ──
deleted_empty = 0
deleted_throwaway = 0
@@ -93,47 +84,13 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
if is_session_recently_active(row, now=cleanup_now):
continue
msg_count = db.query(DbMsg.id).filter(
has_messages = db.query(DbMsg.id).filter(
DbMsg.session_id == row.id
).limit(_THROWAWAY_MAX_MESSAGES + 1).count()
should_delete = False
if msg_count == 0:
).first() is not None
if not has_messages:
if is_fresh:
continue
should_delete = True
deleted_empty += 1
elif delete_throwaway and msg_count <= _THROWAWAY_MAX_MESSAGES:
name = (row.name or "").strip().lower()
first_msg = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id, DbMsg.role == "user"
).order_by(DbMsg.timestamp).first()
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
assistant_count = db.query(DbMsg.id).filter(
DbMsg.session_id == row.id, DbMsg.role == "assistant"
).limit(1).count()
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
should_delete = True
deleted_throwaway += 1
elif msg_count == 1 and assistant_count == 0:
should_delete = True
deleted_throwaway += 1
elif msg_count <= 4 and first_text and len(first_text.split()) <= 8 and len(first_text) <= 80:
# Short trivial chats — e.g. "write hi to a friend" → "Hi!"
should_delete = True
deleted_throwaway += 1
else:
# Aggressive: total message text under 250 chars combined = trivial
msg_rows = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id
).all()
total_chars = sum(len(m[0] or "") for m in msg_rows)
if total_chars <= 250:
should_delete = True
deleted_throwaway += 1
if should_delete:
db.delete(row)
if deleted_empty or deleted_throwaway:
+22 -2
View File
@@ -132,6 +132,23 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
_shared_cache_pending.pop(key, None)
def is_valid_cron(cron_expression: str) -> bool:
"""True for a standard 5-field cron expression that croniter can parse.
croniter also accepts 6- and 7-field expressions with a seconds field, but
the scheduler works at one-minute resolution and would run such a task
every second.
"""
if len((cron_expression or "").split()) != 5:
return False
try:
from croniter import croniter
croniter(cron_expression)
except Exception:
return False
return True
def compute_next_run(schedule: str, scheduled_time: str,
scheduled_day: int = None,
scheduled_date: datetime = None,
@@ -175,6 +192,9 @@ def compute_next_run(schedule: str, scheduled_time: str,
return dt.astimezone(timezone.utc).replace(tzinfo=None)
if schedule == "cron" and cron_expression:
if not is_valid_cron(cron_expression):
logger.warning(f"Invalid cron expression '{cron_expression}'")
return None
try:
from croniter import croniter
cron = croniter(cron_expression, now)
@@ -306,7 +326,7 @@ def _digest_windows(now):
def _checkin_calendar_events(db, owner, start, end):
"""Calendar events in [start, end] for ONE owner, for the check-in digest.
"""Calendar events in [start, end) for ONE owner, for the check-in digest.
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
The digest query had no owner scope, so it pulled EVERY user's events into
@@ -320,7 +340,7 @@ def _checkin_calendar_events(db, owner, start, end):
.filter(
_CC.owner == owner,
_CE.dtstart >= start,
_CE.dtstart <= end,
_CE.dtstart < end,
_CE.status != "cancelled",
)
.order_by(_CE.dtstart)
+1 -1
View File
@@ -410,7 +410,7 @@ _PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
"manage_endpoints": frozenset({"add", "delete", "enable", "disable"}),
"manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}),
"manage_settings": frozenset(
{"set", "delete", "reset", "disable_tool", "enable_tool"}
{"set", "delete", "reset", "disable_tool"}
),
"manage_tokens": frozenset({"create", "delete"}),
"manage_webhooks": frozenset({"add", "delete", "enable", "disable"}),
+1 -1
View File
@@ -145,7 +145,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"manage_tokens": "API token management: list, create, or delete API access tokens.",
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also toggles tools on/off (disable_tool/enable_tool/list_tools). Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also turns tools off globally (disable_tool) and lists disabled ones (list_tools); re-enabling is only done by the user in Settings > Agent Tools. Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
"create_session": "Create a new chat with a name and model.",
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
"send_to_session": "Send a message to another chat. Cross-chat communication.",
+4 -3
View File
@@ -41,6 +41,7 @@ _REQUIRED_NATIVE_TOOL_ARGS = {
"write_file": ("path",),
"edit_file": ("path",),
"apply_patch": ("patch_text", "patchText", "patch"),
"generate_image": ("prompt",),
"host_shell": ("command",),
}
@@ -1292,14 +1293,14 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "manage_settings",
"description": "Manage user preferences and settings. Use `disable_tool`/`enable_tool`/`list_tools` to turn individual tools on or off globally (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Use list/get/set/delete for free-form preferences.",
"description": "Manage user preferences and settings. Use `disable_tool`/`list_tools` to turn individual tools off globally or list what is disabled (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Disabled tools can only be re-enabled by the user in Settings > Agent Tools, never from chat. Use list/get/set/delete for free-form preferences.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "enable_tool", "list_tools"]},
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "list_tools"]},
"key": {"type": "string", "description": "Setting key (for get/set/delete)"},
"value": {"description": "Setting value (for set) — can be string, number, boolean, or object"},
"tool": {"type": "string", "description": "Tool name to disable/enable (for disable_tool/enable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
"tool": {"type": "string", "description": "Tool name to disable (for disable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
},
"required": ["action"]
}
+6
View File
@@ -769,6 +769,8 @@ _APP_API_BLOCKLIST_PREFIXES = (
# {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
# Use dedicated tools or UI flows instead.
_APP_API_BLOCKLIST_METHOD_PATH = (
("POST", "/api/tools"), # global tool policy is changed by a human in Settings
("PATCH", "/api/mcp/servers"), # per-server MCP tool policy and server toggles, same reason
("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool
("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead
("DELETE", "/api/cookbook/state"),
@@ -893,6 +895,10 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
return {"error": f"Unsupported method: {method}", "exit_code": 1}
if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
if path.startswith("/api/tools"):
return {"error": "Tool policy cannot be replaced from chat. Re-enable tools in Settings > Agent Tools; use manage_settings to list or disable them.", "exit_code": 1}
if path.startswith("/api/mcp/servers"):
return {"error": "MCP server and tool policy cannot be changed from chat. Ask the user to change it in Settings > Integrations.", "exit_code": 1}
if "/api/email/accounts" in path:
return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1}
if "/api/cookbook/packages/install" in path:
+24 -1
View File
@@ -155,11 +155,34 @@ body {
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
/* Self-hosted editor text fonts (SIL OFL 1.1). These are bundled so the text
tool renders distinct, expected faces on every device — phones (Android/iOS)
do not ship Arial/Verdana/Georgia/Times/Courier/Impact, which previously
collapsed every font choice onto the same system fallback. */
@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-SemiBold.woff2') format('woff2'); }
@font-face { font-family: 'Roboto'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Playfair Display'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Playfair Display'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Lobster'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Lobster-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Bebas Neue'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/BebasNeue-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto Mono'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto Mono'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Syne'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Syne'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Bungee'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Bungee-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Instrument Serif'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/InstrumentSerif-Regular.woff2') format('woff2'); }
/* Code block baseline */
pre, code, .hljs {
font-size: 11px;
+69 -8
View File
@@ -315,7 +315,7 @@ body.bg-pattern-ascii-fireflies {
width: 240px;
background: var(--sidebar-bg, var(--panel));
border-right: 1px solid var(--border);
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease, visibility 0.25s;
display: flex;
flex-direction: column;
flex-shrink: 0;
@@ -361,12 +361,23 @@ body.bg-pattern-ascii-fireflies {
/* !important so it beats the inline width init.js restores from storage —
otherwise the width never changes and only opacity animates, making the
collapse look instant. With this, width animates from the inline value
down to 0 via the .sidebar width transition. */
down to 0 via the .sidebar width transition.
display overrides the global .hidden utility, which would otherwise
remove the box before any transition can run. visibility (transitioned
on .sidebar) flips to hidden only once the collapse has finished,
taking the closed sidebar out of the tab order. */
display: flex;
width: 0 !important;
padding: 0 !important;
border: none;
overflow: hidden;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
/* IDs so this also beats the mobile drawer's !important transition. */
@media (prefers-reduced-motion: reduce) {
#sidebar, #icon-rail { transition: none !important; }
}
/* ===== Sidebar User Bar ===== */
.sidebar-user-bar {
@@ -449,10 +460,6 @@ body.bg-pattern-ascii-fireflies {
}
.sidebar.hidden .sidebar-user-bar {
display: none;
}
/* Sticky sidebar header — logo, never scrolls */
.sidebar-header {
display: flex;
@@ -673,6 +680,24 @@ body.bg-pattern-ascii-fireflies {
margin-bottom: calc(-1 * var(--safe-bottom));
padding-bottom: calc(8px + var(--safe-bottom));
}
/* Tuck the rail under the sidebar (which paints above it) and slide it
out as the sidebar collapses, so the combined width eases instead of
jumping by the rail's 48px when the two swap. */
.icon-rail,
.icon-rail.right-side {
margin-inline-end: -48px;
transition: margin-inline-end 0.25s ease, display 0.25s allow-discrete;
}
body.sidebar-collapsed:not(.hamburger-only) .icon-rail,
html.ody-sidebar-mini .icon-rail {
margin-inline-end: 0;
}
@starting-style {
body.sidebar-collapsed:not(.hamburger-only) .icon-rail,
html.ody-sidebar-mini .icon-rail {
margin-inline-end: -48px;
}
}
}
.icon-rail-divider {
width: 24px;
@@ -4567,7 +4592,9 @@ body.bg-pattern-ascii-fireflies {
width:100%; background:var(--bg); color:var(--fg);
border:1px solid var(--border); border-radius:6px;
padding:8px; font-family:inherit; font-size:0.95rem;
resize:vertical; outline:none;
resize:both; outline:none;
field-sizing:content;
max-width:100%; box-sizing:border-box;
min-height:80px;
}
.edit-textarea:focus { border-color:var(--red); }
@@ -5116,7 +5143,7 @@ body.bg-pattern-ascii-fireflies {
width: 80% !important;
max-width: 340px;
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
transition: transform 0.25s ease, opacity 0.25s ease !important;
transition: transform 0.25s ease, opacity 0.25s ease, visibility 0.25s !important;
opacity: 1 !important;
overflow: visible !important;
/* Force opaque background — Firefox mobile renders the
@@ -5147,6 +5174,7 @@ body.bg-pattern-ascii-fireflies {
}
html.ody-sidebar-off .sidebar,
html.ody-mobile-startup-sidebar-hidden .sidebar {
width: 80% !important;
transform: translateX(-100%) !important;
pointer-events: none !important;
opacity: 0 !important;
@@ -7230,6 +7258,39 @@ body.bg-pattern-ascii-fireflies {
text-align: center;
}
/* Inline assistant markdown images (`![alt](url)`) — rendered by
static/js/markdown.js as a.clickable <a class="chat-image"> wrapper. */
a.chat-image {
display: inline-block;
max-width: 100%;
margin: 0.35rem 0;
}
a.chat-image img.chat-inline-image {
display: block;
max-width: 100%;
height: auto;
border-radius: 8px;
cursor: zoom-in;
}
a.chat-image .chat-image-fallback {
display: none;
font-size: 0.8rem;
color: var(--color-muted, #94a3b8);
word-break: break-all;
padding: 0.5rem 0.75rem;
border: 1px dashed var(--border, #475569);
border-radius: 8px;
background: var(--panel, #202428);
}
a.chat-image.image-broken img.chat-inline-image {
display: none;
}
a.chat-image.image-broken .chat-image-fallback {
display: block;
text-decoration: underline;
cursor: pointer;
}
/* Setup wizard */
.setup-wizard { padding: 16px; max-width: 500px; }
.setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); }
+14
View File
@@ -22,6 +22,20 @@
/* Session items */
.session-item { position: relative; }
/* Title fade: the server auto-renames a chat shortly after its first
prompt, which re-renders the sidebar. Fade the new title in so the
change reads as smooth rather than a snap. */
.session-item .grow.session-title-updated {
animation: sessionTitleFade 0.5s ease;
}
@keyframes sessionTitleFade {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.session-item .grow.session-title-updated { animation: none; }
}
.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-menu-btn { padding: 0 2px !important; min-width: 20px; height: 20px; display: inline-flex !important; align-items: center; justify-content: center; background: none !important; border-color: transparent !important; }
.session-menu-btn:hover { background: none !important; border-color: transparent !important; }
+1
View File
@@ -704,6 +704,7 @@ textarea.memory-add-input {
#serve-bulk-bar,
#gallery-bulk-bar,
#gallery-editor-drafts-bulk,
#notes-bulk-bar,
#memory-modal .memory-bulk-bar {
padding-right: 18px;
}
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -2690,11 +2690,14 @@ body.theme-frosted .modal {
max-height: 95dvh !important;
}
/* Notes and Documents must respect the same viewport and safe-area
top as other mobile full-height sheets. Keep these authoritative rules
consistent with 05-documents.css and 14-email.css. */
body.doc-view .doc-editor-pane,
body.notes-view .notes-pane {
inset: auto 0 0 !important;
height: 95dvh !important;
max-height: 95dvh !important;
inset: var(--sheet-top) 0 0 !important;
height: calc(var(--app-height) - var(--sheet-top)) !important;
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
}
}
+340
View File
@@ -978,3 +978,343 @@ body.doc-find-active mark.doc-find-mark.current {
color: var(--accent, var(--red));
vertical-align: -2px;
}
/* Colour chips: every pickable colour input renders as a circle, regardless of
the container it sits in (options bar .ge-text-field, Character panel,
gradient stops, export matte, etc.). Forced with !important so parent
input[type="color"] rules can't square them off. */
input.ge-color-picker,
input.ge-color-picker.cp-swatch-input,
.ge-text-field input.ge-color-picker,
.ge-swatch input.ge-color-picker,
.ge-filter-row input.ge-color-picker,
.ge-gradient-color-row input.ge-color-picker {
border-radius: 50% !important;
}
input.ge-color-picker::-webkit-color-swatch {
border: 0 !important;
border-radius: 50% !important;
}
input.ge-color-picker::-moz-color-swatch {
border: 0 !important;
border-radius: 50% !important;
}
/* ---------------------------------------------------------------------------
Editor theme alignment: map the editor's controls onto the app's design
tokens (static/css/00-tokens.css) so the editor matches the rest of the
product. No new colours — only the shared CSS variables.
--------------------------------------------------------------------------- */
/* Checkboxes + radios → the app's circular dot (see 17-research.css). */
body.ge-editor-open input[type="checkbox"],
body.ge-editor-open input[type="radio"] {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin: 0;
padding: 0;
border: 1.5px solid var(--border);
border-radius: 50%;
background: var(--bg);
cursor: pointer;
position: relative;
flex: 0 0 auto;
vertical-align: middle;
transition: background 0.12s ease, border-color 0.12s ease;
}
body.ge-editor-open input[type="checkbox"]:checked,
body.ge-editor-open input[type="radio"]:checked {
background: var(--fg);
border-color: var(--fg);
}
body.ge-editor-open input[type="checkbox"]:focus-visible,
body.ge-editor-open input[type="radio"]:focus-visible {
outline: 2px solid var(--red);
outline-offset: 1px;
}
/* Selects → the app's select tokens. */
body.ge-editor-open select {
background: var(--select-bg, var(--bg));
color: var(--select-fg, var(--fg));
border: 1px solid var(--border);
border-radius: 6px;
}
body.ge-editor-open select option {
background: var(--select-option-bg, var(--panel));
color: var(--select-option-fg, var(--fg));
}
body.ge-editor-open select option:checked,
body.ge-editor-open select option:hover {
background: var(--select-option-active-bg, var(--panel));
}
body.ge-editor-open select:focus-visible {
outline: 1px solid var(--red);
outline-offset: 1px;
}
/* Text / number inputs → token background, border and focus ring. */
body.ge-editor-open input[type="text"],
body.ge-editor-open input[type="number"],
body.ge-editor-open input[type="search"],
body.ge-editor-open textarea {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
}
body.ge-editor-open input[type="text"]:focus-visible,
body.ge-editor-open input[type="number"]:focus-visible,
body.ge-editor-open input[type="search"]:focus-visible,
body.ge-editor-open textarea:focus-visible {
outline: 1px solid var(--red);
outline-offset: 1px;
}
/* Range sliders → the app's accent. */
body.ge-editor-open input[type="range"] {
accent-color: var(--accent, var(--red));
}
/* Scrollbars → the app's 8px red-thumb recipe (00-tokens.css). */
body.ge-editor-open * {
scrollbar-width: thin;
scrollbar-color: var(--red) var(--panel);
}
body.ge-editor-open *::-webkit-scrollbar {
width: 8px;
height: 8px;
}
body.ge-editor-open *::-webkit-scrollbar-track {
background: var(--panel);
}
body.ge-editor-open *::-webkit-scrollbar-thumb {
background-color: var(--red);
border-radius: 4px;
border: 2px solid var(--panel);
}
body.ge-editor-open *::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--red) 80%, white);
}
/* ---------------------------------------------------------------------------
Editor theme alignment (part 2): buttons, segmented toggles, tabs, list
rows, panel headers, badges, focus rings, number fields and colour-picker
chrome mapped onto the app's shared design tokens. Existing tokens only —
no new colours, and no geometry beyond colour / border / radius / focus.
--------------------------------------------------------------------------- */
/* The editor references two secondary / muted variables that the app token
layer never defines; map them onto the real tokens so every
var(--fg-muted) / var(--bg-secondary) editor rule resolves. */
body.ge-editor-open {
--fg-muted: var(--color-muted);
--bg-secondary: var(--panel);
}
/* Buttons → the app's `.cal-btn` recipe (5px radius; the primary button
uses the accent fill with a --bg foreground instead of a hard-coded #fff). */
body.ge-editor-open .ge-btn {
border-radius: 5px;
}
body.ge-editor-open .ge-btn-primary {
background: var(--accent, var(--red));
border-color: var(--accent, var(--red));
color: var(--bg);
}
body.ge-editor-open .ge-btn-primary:hover {
background: var(--accent, var(--red));
opacity: 0.9;
}
body.ge-editor-open .ge-win-close:hover {
background: var(--accent, var(--red));
color: var(--bg);
}
/* Segmented / option toggles, command palette and channel rows → the app's
accent token (--red) instead of the editor's blue #4c8bf5 fallback. */
body.ge-editor-open .ge-opt-mode.active,
body.ge-editor-open .ge-crop-preset.active,
body.ge-editor-open .ge-command-item.active,
body.ge-editor-open .ge-command-item:hover {
background: var(--accent, var(--red));
border-color: var(--accent, var(--red));
color: var(--bg);
}
body.ge-editor-open .ge-channel-row.active,
body.ge-editor-open .ge-dock-tabs-menu-item:hover,
body.ge-editor-open .ge-filter-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-view-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-window-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-help-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-3d-menu .dropdown-item-compact:focus-visible,
body.ge-editor-open .ge-plugins-menu .dropdown-item-compact:focus-visible {
background: color-mix(in srgb, var(--accent, var(--red)) 22%, transparent);
}
body.ge-editor-open .ge-sam-brush-add.active,
body.ge-editor-open .ge-sam-brush-subtract.active,
body.ge-editor-open .ge-actions-controls .ge-btn.active {
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
}
/* Tabs → the app tab convention: the active tab's underline is the accent. */
body.ge-editor-open .ge-dock-tab.active {
border-bottom-color: var(--accent, var(--red));
}
/* Colour-picker chrome → the token accent instead of the blue fallback. */
body.ge-editor-open .ge-swatch.active {
outline-color: var(--accent, var(--red));
}
/* Badges / status pills → the app's --bg foreground on an accent fill. */
body.ge-editor-open .ge-tool-clear {
color: var(--bg);
}
/* Panel headers → the app's muted section-title token. */
body.ge-editor-open .ge-panel-title,
body.ge-editor-open .ge-info-label {
color: var(--color-muted);
}
/* Focus rings → the app's 1px --red ring (matching the earlier input/select
block) on the controls that previously fell back to the UA outline. */
body.ge-editor-open .ge-btn:focus-visible,
body.ge-editor-open .ge-tool-btn:focus-visible,
body.ge-editor-open .ge-doc-tab:focus-visible,
body.ge-editor-open .ge-history-row:focus-visible,
body.ge-editor-open .ge-swatch:focus-visible,
body.ge-editor-open .ge-opt-mode:focus-visible,
body.ge-editor-open .ge-stepper button:focus-visible {
outline: 1px solid var(--red);
outline-offset: 1px;
}
/* ---------------------------------------------------------------------------
Editor theme alignment (part 3): the setup / preflight checklist and the
remaining checkbox list-row / badge chrome mapped onto the app's shared
design tokens. Existing tokens only — no new colours, and no geometry beyond
colour / border / radius / focus. Scoped to body.ge-editor-open.
--------------------------------------------------------------------------- */
/* Checklist card + rows → the app's panel / border / muted tokens. */
body.ge-editor-open .ge-preflight-card {
background: var(--panel);
color: var(--fg);
border-color: var(--border);
}
body.ge-editor-open .ge-preflight-head p,
body.ge-editor-open .ge-preflight-hint,
body.ge-editor-open .ge-preflight-detail,
body.ge-editor-open .ge-preflight-remember,
body.ge-editor-open .ge-preflight-close {
color: var(--color-muted);
}
body.ge-editor-open .ge-preflight-close:hover {
background: color-mix(in srgb, var(--fg) 8%, transparent);
color: var(--fg);
}
body.ge-editor-open .ge-preflight-row {
background: color-mix(in srgb, var(--panel) 92%, var(--fg) 4%);
border-color: var(--border);
}
body.ge-editor-open .ge-preflight-row.is-done {
border-color: color-mix(in srgb, var(--green) 45%, var(--border));
}
body.ge-editor-open .ge-preflight-row.is-todo {
border-color: var(--red);
}
body.ge-editor-open .ge-preflight-row.is-optional {
border-color: color-mix(in srgb, var(--accent-warm) 45%, var(--border));
}
/* Row badges → the semantic tokens (done = green, todo = red, optional = warm). */
body.ge-editor-open .ge-preflight-badge {
background: color-mix(in srgb, var(--fg) 10%, transparent);
color: var(--fg);
}
body.ge-editor-open .ge-preflight-row.is-done .ge-preflight-badge {
background: color-mix(in srgb, var(--green) 20%, transparent);
color: var(--green);
}
body.ge-editor-open .ge-preflight-row.is-todo .ge-preflight-badge {
background: color-mix(in srgb, var(--red) 18%, transparent);
color: var(--red);
}
body.ge-editor-open .ge-preflight-row.is-optional .ge-preflight-badge {
background: color-mix(in srgb, var(--accent-warm) 16%, transparent);
color: var(--accent-warm);
}
body.ge-editor-open .ge-preflight-opt {
background: color-mix(in srgb, var(--fg) 8%, transparent);
color: var(--color-muted);
}
/* Checklist actions / footer → token buttons and borders. */
body.ge-editor-open .ge-preflight-act {
background: color-mix(in srgb, var(--fg) 8%, transparent);
border-color: var(--border);
color: var(--fg);
}
body.ge-editor-open .ge-preflight-act:hover {
background: color-mix(in srgb, var(--fg) 16%, transparent);
}
body.ge-editor-open .ge-preflight-foot {
border-top-color: var(--border);
}
body.ge-editor-open .ge-preflight-summary {
color: var(--green);
}
body.ge-editor-open .ge-preflight-start {
background: var(--accent, var(--red));
color: var(--bg);
}
body.ge-editor-open .ge-preflight-start:hover {
background: var(--accent, var(--red));
opacity: 0.9;
}
body.ge-editor-open .ge-setup-btn {
border-color: var(--border);
color: var(--color-muted);
}
body.ge-editor-open .ge-setup-btn:hover {
background: color-mix(in srgb, var(--fg) 10%, transparent);
color: var(--fg);
}
/* The options-bar checkbox overrode the shared dot with a square + a drawn
::after tick; force it back to the app's 14px circular dot, filled with the
highlight (accent) colour when checked (part 1). */
body.ge-editor-open .ge-opt input[type="checkbox"],
body.ge-editor-open .ge-opt input[type="radio"] {
border-radius: 50%;
border-width: 1.5px;
}
body.ge-editor-open .ge-opt input[type="checkbox"]:checked,
body.ge-editor-open .ge-opt input[type="radio"]:checked {
background: var(--accent, var(--red));
border-color: var(--accent, var(--red));
}
body.ge-editor-open .ge-opt input[type="checkbox"]:checked::after {
content: none;
}
/* Remaining list-row / thumbnail chrome → panel + border + muted tokens. */
body.ge-editor-open .ge-history-row-dot {
background: var(--color-muted);
}
body.ge-editor-open .ge-layer-inline-thumb {
background: var(--panel);
border-color: var(--border);
}
body.ge-editor-open .ge-channel-row,
body.ge-editor-open .ge-channel-name {
color: var(--fg);
}
body.ge-editor-open .ge-channel-eye {
color: var(--color-muted);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -7
View File
@@ -16,13 +16,6 @@
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}">
window._odysseusLoadTime = Date.now();
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
(function(){
var ua = navigator.userAgent || '';
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
var vp = document.querySelector('meta[name="viewport"]');
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
})();
(function(){
try {
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
@@ -664,6 +657,7 @@
<div class="theme-fd-group">
<label class="theme-fd-label">Font</label>
<select id="theme-font-select" class="theme-fd-select" aria-label="Font">
<option value="gohu">GohuFont</option>
<option value="mono">Monospace</option>
<option value="sans">Sans-serif</option>
<option value="serif">Serif</option>
+61 -1
View File
@@ -18,7 +18,13 @@
'use strict';
// Click-as-button rows we want reachable by keyboard.
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
var ROW_SELECTOR = [
'#sidebar .list-item',
'#user-bar-profile',
'.sidebar-brand',
'.export-dropdown-item',
'.dropdown-item'
].join(',');
// Native interactive descendants. If a row contains one of these we must
// NOT give the row role="button" — a button inside a button is invalid
@@ -55,6 +61,31 @@
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
}
// Settings form labels are written as <label class="settings-label">Text</label>
// with the control as a sibling/descendant, but are not associated via for/id,
// so screen readers cannot name the inputs. Associate each label with its
// control when the mapping is unambiguous (a single form control per row).
var settingsLabelSeq = 0;
function associateSettingsLabels(root) {
(root || document).querySelectorAll('.settings-label').forEach(function (label) {
if (label.hasAttribute('for')) return;
var control = null;
var sib = label.nextElementSibling;
if (sib && /^(INPUT|SELECT|TEXTAREA)$/.test(sib.tagName)) {
control = sib;
} else {
var row = label.closest('.settings-row') || label.parentElement;
if (row) {
var controls = row.querySelectorAll('input,select,textarea');
if (controls.length === 1) control = controls[0];
}
}
if (!control || control.closest('label')) return;
if (!control.id) control.id = 'a11y-settings-' + (++settingsLabelSeq);
if (!control.getAttribute('aria-label')) label.setAttribute('for', control.id);
});
}
// ---- Modal dialogs -----------------------------------------------------
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
// them as ARIA dialogs lets screen readers announce them as dialogs and
@@ -118,9 +149,38 @@
el.click();
});
// Disclosure buttons: keep aria-expanded in sync with their controlled
// menu/dropdown, regardless of which module toggles its visibility.
var DISCLOSURE_MAP = [
{ button: 'session-sort-btn', menu: 'session-sort-dropdown' },
{ button: 'export-dl-btn', menu: 'export-dropdown-menu' },
{ button: 'overflow-plus-btn', menu: 'overflow-menu' }
];
function syncDisclosures() {
DISCLOSURE_MAP.forEach(function (d) {
var btn = document.getElementById(d.button);
if (!btn) return;
var menu = document.getElementById(d.menu);
var open = false;
if (menu) {
var cs = window.getComputedStyle(menu);
open = cs.display !== 'none' && menu.offsetWidth > 0;
}
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
function init() {
enhanceAll(document);
enhanceModals(document);
associateSettingsLabels(document);
// Any click (the dropdown toggles, outside-click closes, etc.) may change
// a disclosure's open state; re-sync on the next tick.
document.addEventListener('click', function () {
if (window.setTimeout) setTimeout(syncDisclosures, 0);
});
// Sidebar content is re-rendered as the user navigates (session lists,
// tool sub-rows, etc.). Watch for new rows and enhance them too.
+7 -5
View File
@@ -763,7 +763,7 @@ function renderFeatherlessPanel(panel, ep, row) {
const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b));
enabledListEl.innerHTML = sortedIds.map(id => {
const displayName = id.split('/').pop() || id;
const mode = ['none', 'compact', 'full'].includes(String(toolModes[id] || '').toLowerCase())
const mode = ['none', 'compact', 'full', 'spec'].includes(String(toolModes[id] || '').toLowerCase())
? String(toolModes[id]).toLowerCase()
: '';
return `<div title="${esc(id)}" data-ep-model-row data-model-id="${esc(id)}" class="adm-model-row">
@@ -777,7 +777,8 @@ function renderFeatherlessPanel(panel, ep, row) {
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(id)}" data-original-tool-mode="${esc(toolModes[id] || '')}">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
</div>
@@ -1356,7 +1357,7 @@ async function loadEndpoints() {
<a href="#" data-ep-select-none="${epId}">None</a>
</span>
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m => {
const mode = ['none', 'compact', 'full'].includes(String(m.tool_mode || '').toLowerCase())
const mode = ['none', 'compact', 'full', 'spec'].includes(String(m.tool_mode || '').toLowerCase())
? String(m.tool_mode).toLowerCase()
: '';
return `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
@@ -1370,7 +1371,8 @@ async function loadEndpoints() {
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Auto uses Odysseus compact for Odysseus/Ajax names and Regular tools for every other model">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
</div>
@@ -1440,7 +1442,7 @@ async function _saveEpModelState(epId, panel) {
const value = String(sel.value || '').toLowerCase();
const original = String(sel.dataset.originalToolMode || '').toLowerCase();
if (modelId && (sel.dataset.toolModeTouched === '1' || original)) {
modelToolModes[modelId] = ['none', 'compact', 'full'].includes(value) ? value : '';
modelToolModes[modelId] = ['none', 'compact', 'full', 'spec'].includes(value) ? value : '';
}
});
const total = panel.querySelectorAll('input.adm-cb-hidden[type=checkbox]').length;
+7 -1
View File
@@ -771,7 +771,13 @@ function _getModal() {
<button class="cal-side-nav cal-side-nav-next" id="cal-side-next" title="Next" aria-label="Next">&rsaquo;</button>
</div>`;
document.body.appendChild(_modal);
_modal.querySelector('#cal-close').addEventListener('click', closeCalendar);
_modal.querySelector('#cal-close').addEventListener('click', () => {
if (_modal.querySelector('.cal-form')) {
_render();
return;
}
closeCalendar();
});
_modal.querySelector('#cal-side-prev')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
+279
View File
@@ -44,6 +44,283 @@ import { invalidateSettings } from './appConfig.js';
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
// Inline chat images share the editor's 8192px dimension ceiling, with a
// conservative 16MP budget for its multiple canvas surfaces. No blob URLs are
// needed here: chat previews use server-authorized upload references.
const _MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
const _MAX_INLINE_BYTES = 7 * 1024 * 1024;
const _MAX_IMAGE_DIMENSION = 8192;
const _MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
let _inlineRasterCache = null; // One bounded source; repeated actions reuse its decode.
function _imageDimensionsAllowed(width, height) {
return width > 0 && height > 0 && width <= _MAX_IMAGE_DIMENSION &&
height <= _MAX_IMAGE_DIMENSION && width * height <= _MAX_IMAGE_PIXELS;
}
function _rasterDimensions(bytes, mime) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ascii = (offset, text) => [...text].every((char, i) => bytes[offset + i] === char.charCodeAt(0));
if (mime === 'image/png' && bytes.length >= 33 &&
bytes[0] === 137 && ascii(1, 'PNG\r\n\x1a\n') && view.getUint32(8) === 13 && ascii(12, 'IHDR')) {
return [view.getUint32(16), view.getUint32(20)];
}
if (mime === 'image/gif' && bytes.length >= 13 && (ascii(0, 'GIF87a') || ascii(0, 'GIF89a'))) {
return [view.getUint16(6, true), view.getUint16(8, true)];
}
if (mime === 'image/jpeg' && bytes[0] === 255 && bytes[1] === 216) {
let pos = 2;
while (pos + 4 <= bytes.length) {
if (bytes[pos++] !== 255) return null;
while (bytes[pos] === 255) pos++;
const marker = bytes[pos++];
if (marker === 218 || marker === 217) break;
if (marker === 1 || (marker >= 208 && marker <= 215)) continue;
if (pos + 2 > bytes.length) return null;
const size = view.getUint16(pos);
if (size < 2 || pos + size > bytes.length) return null;
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker) && size >= 8) {
return [view.getUint16(pos + 5), view.getUint16(pos + 3)];
}
pos += size;
}
}
if (mime === 'image/webp' && bytes.length >= 30 && ascii(0, 'RIFF') &&
view.getUint32(4, true) + 8 === bytes.length && ascii(8, 'WEBP')) {
if (ascii(12, 'VP8X')) return [1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16), 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16)];
if (ascii(12, 'VP8L') && bytes[20] === 47) return [1 + ((bytes[21] | (bytes[22] << 8)) & 16383), 1 + (((bytes[22] >> 6) | (bytes[23] << 2) | (bytes[24] << 10)) & 16383)];
if (ascii(12, 'VP8 ') && bytes[23] === 157 && bytes[24] === 1 && bytes[25] === 42) return [view.getUint16(26, true) & 16383, view.getUint16(28, true) & 16383];
}
return null;
}
function _inlineRaster(url) {
if (_inlineRasterCache?.url === url) return _inlineRasterCache;
const result = { url, valid: false };
_inlineRasterCache = result;
if (url.length > _MAX_DATA_URL_LENGTH) return result;
const match = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,/i.exec(url);
if (!match) return result;
const payload = url.slice(match[0].length);
if (!payload || payload.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) return result;
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
const size = payload.length / 4 * 3 - padding;
if (size > _MAX_INLINE_BYTES) return result;
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
if (padding && (alphabet.indexOf(payload[payload.length - padding - 1]) & (padding === 2 ? 15 : 3))) return result;
try {
const bytes = typeof Uint8Array.fromBase64 === 'function' ? Uint8Array.fromBase64(payload) : (() => {
const binary = atob(payload); const array = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
return array;
})();
const mime = match[1].toLowerCase().replace('image/jpg', 'image/jpeg');
const dimensions = _rasterDimensions(bytes, mime);
if (!dimensions || !_imageDimensionsAllowed(...dimensions)) return result;
Object.assign(result, { valid: true, bytes, mime, dimensions });
} catch (_) {}
return result;
}
// This synchronous guard rejects untrusted references before any fetch/decode.
// Inline images additionally require a successful browser decode before launch.
function _isAuthorizedImageUrl(url) {
if (!url || typeof url !== 'string') return false;
const trimmed = url.trim();
if (trimmed.startsWith('data:')) return _inlineRaster(trimmed).valid;
if (!trimmed || trimmed.startsWith('blob:') || trimmed.startsWith('//')) return false;
try {
const base = window.location.origin;
const parsed = new URL(trimmed, base);
return parsed.origin === base && !parsed.search && !parsed.hash &&
/^\/api\/(?:upload|gallery)\/[A-Za-z0-9_\-]+(?:\.[a-zA-Z0-9]+)?$/.test(parsed.pathname);
} catch (_) { return false; }
}
async function _validateInlineImage(url) {
const raster = _inlineRaster(url.trim());
if (!raster.valid) return false;
if (!raster.decoded) raster.decoded = (async () => {
try {
const image = await createImageBitmap(new Blob([raster.bytes], { type: raster.mime }));
try {
return _imageDimensionsAllowed(image.width, image.height) &&
image.width * image.height === raster.dimensions[0] * raster.dimensions[1];
} finally { image.close(); }
} catch (_) { return false; }
finally { raster.bytes = null; }
})();
return raster.decoded;
}
// Find the specific user message that originated the turn containing container.
function _findOriginatingUserMessage(container) {
if (!container) return null;
let curr = container.previousElementSibling;
while (curr) {
if (curr.classList && curr.classList.contains('msg-user')) {
return curr;
}
curr = curr.previousElementSibling;
}
const box = document.getElementById('chat-history');
if (box && box.contains(container)) {
const all = Array.from(box.querySelectorAll('.msg, .agent-thread, .ai-msg-wrap, .round-holder'));
const idx = all.findIndex((el) => el === container || el.contains(container));
if (idx > 0) {
for (let i = idx - 1; i >= 0; i--) {
if (all[i].classList && all[i].classList.contains('msg-user')) {
return all[i];
}
}
}
}
return null;
}
// Resolve the image attached to the exact turn associated with container / action.
// Never falls back to unrelated images from earlier conversation turns.
function _attachedImageForTurn(container, action) {
const explicitId = action && (action.imageId || action.image_id || action.fileId || action.file_id);
if (explicitId && typeof explicitId === 'string' && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(explicitId)) {
return (API_BASE || '') + '/api/upload/' + explicitId;
}
const explicitUrl = action && (action.imageUrl || action.image_url);
if (explicitUrl) return _isAuthorizedImageUrl(explicitUrl) ? explicitUrl : null;
const userMsg = _findOriginatingUserMessage(container);
if (!userMsg) return null;
const previews = userMsg.querySelectorAll(
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
);
for (let i = previews.length - 1; i >= 0; i--) {
const fid = previews[i].dataset ? previews[i].dataset.fileId : null;
if (fid && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(fid)) {
return (API_BASE || '') + '/api/upload/' + fid;
}
const img = previews[i].querySelector('img');
const src = img ? (img.getAttribute('src') || '') : '';
if (_isAuthorizedImageUrl(src)) return src;
}
const imgs = userMsg.querySelectorAll('.attach-image-preview img, .attach-card img');
for (let i = imgs.length - 1; i >= 0; i--) {
const src = imgs[i].getAttribute('src') || '';
if (_isAuthorizedImageUrl(src)) return src;
}
return null;
}
function _currentAttachedImageUrl(container, action) {
return _attachedImageForTurn(container, action);
}
function _notifyError(msg) {
try {
if (uiModule && typeof uiModule.showError === 'function') {
uiModule.showError(msg);
return;
}
} catch (_) {}
console.warn('[chat]', msg);
}
// Render the one-click "Open in editor & apply" button on a photo-edit reply
// that had no open editor (photo_editor_action kind 'open_and_apply').
function renderPhotoEditorApplyButton(container, action) {
if (!container || !action || action.kind !== 'open_and_apply') return;
if (container.querySelector('.chat-open-in-editor-btn')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chat-open-in-editor-btn';
btn.textContent = 'Open in editor & apply';
btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;';
btn.addEventListener('click', async () => {
if (btn.disabled) return;
btn.disabled = true;
try {
let imageUrl = _attachedImageForTurn(container, action);
if (!imageUrl) {
_notifyError('No attached photo found for this conversation turn.');
btn.disabled = false;
return;
}
if (!_isAuthorizedImageUrl(imageUrl)) {
_notifyError('Unauthorized or invalid image reference.');
btn.disabled = false;
return;
}
if (imageUrl.trim().startsWith('data:') && !await _validateInlineImage(imageUrl)) {
_notifyError('Invalid or oversized image attachment.');
return;
}
// Gallery references name an owner-scoped metadata resource, not image bytes.
if (!imageUrl.trim().startsWith('data:') && new URL(imageUrl, window.location.origin).pathname.startsWith('/api/gallery/')) {
const response = await fetch(imageUrl, { credentials: 'same-origin' });
if (!response.ok) { _notifyError('The gallery image is unavailable.'); return; }
const image = await response.json();
const source = new URL(image.url || '', window.location.origin);
if (source.origin !== window.location.origin || source.search || source.hash ||
!/^\/api\/generated-image\/[A-Za-z0-9_.-]+$/.test(source.pathname)) {
_notifyError('Invalid gallery image reference.'); return;
}
imageUrl = source.href;
}
// Verify attachment is accessible and authorized on the server before launching editor
if (imageUrl.includes('/api/upload/')) {
try {
const probe = await fetch(imageUrl, { method: 'HEAD' });
if (!probe.ok) {
if (probe.status === 404 || probe.status === 410) {
_notifyError('The attached image is no longer available or upload has expired.');
} else if (probe.status === 401 || probe.status === 403) {
_notifyError('You are not authorized to edit this attached image.');
} else {
_notifyError('Could not verify image attachment authorization.');
}
btn.disabled = false;
return;
}
} catch (_) {
_notifyError('Could not verify image attachment authorization.');
btn.disabled = false;
return;
}
}
if (typeof window.gePhotoEditorApply !== 'function') {
await import('/static/js/galleryEditor.js?v=20260909movepicklayer1');
}
await window.gePhotoEditorApply({
imageId: (action && (action.imageId || action.image_id)) || null,
imageUrl,
prompt: action.prompt || '',
displayName: 'Chat edit',
});
} catch (err) {
console.error('[chat] open_and_apply failed', err);
_notifyError('Failed to apply edit: ' + (err.message || err));
} finally {
btn.disabled = false;
}
});
container.appendChild(btn);
}
if (typeof window !== 'undefined') {
window.__chatPhotoEditorTestHooks = {
_isAuthorizedImageUrl,
_findOriginatingUserMessage,
_attachedImageForTurn,
renderPhotoEditorApplyButton,
};
}
let API_BASE = '';
let currentAbort = null;
let isStreaming = false;
@@ -4095,6 +4372,7 @@ import { invalidateSettings } from './appConfig.js';
threadAbove.classList.add('has-bottom');
}
terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope);
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
lastContentRoundHolder = roundHolder || lastContentRoundHolder;
uiModule.scrollHistory();
continue;
@@ -6519,6 +6797,7 @@ import { invalidateSettings } from './appConfig.js';
gotDelta = true;
try { spinner.destroy(); } catch (_) {}
renderDelta(json);
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
replayRendering.settle();
} else if (json.delta) {
if (!replayRendering.accepts(json)) continue;
+5 -1
View File
@@ -457,6 +457,7 @@ function _makeActionBtn(className, title, text, handler) {
btn.className = className;
btn.type = 'button';
btn.title = title;
btn.setAttribute('aria-label', title);
btn.textContent = text;
btn.addEventListener('click', handler);
return btn;
@@ -2375,6 +2376,7 @@ export function createMsgFooter(msgElement, { animate = false } = {}) {
moreBtn.className = 'msg-action-btn msg-more-btn';
moreBtn.type = 'button';
moreBtn.title = 'More actions';
moreBtn.setAttribute('aria-label', 'More actions');
moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -2571,6 +2573,7 @@ export function createUserMsgFooter(msgElement) {
moreBtn.className = 'msg-action-btn msg-more-btn';
moreBtn.type = 'button';
moreBtn.title = 'More actions';
moreBtn.setAttribute('aria-label', 'More actions');
moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -3443,7 +3446,8 @@ export function addMessage(role, content, modelName, metadata) {
for (const ev of roundTools) {
const image = generatedImageResult(ev);
if (image) {
box.appendChild(buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id));
const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id);
box.appendChild(imgBubble);
}
}
}
+2
View File
@@ -212,6 +212,8 @@ export function handleUIControl(uiData) {
var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes));
if (fn) fn();
}).catch(function(){});
} else if (panel === 'tasks') {
document.getElementById('tool-tasks-btn')?.click();
} else if (panel === 'theme' || panel === 'themes') {
import('./theme.js?v=20260911organsrain1').then(function(mod) {
var fn = mod.togglePopup || (mod.default && mod.default.togglePopup);
+3 -3
View File
@@ -390,11 +390,11 @@ export function runHTML(code, panel) {
* Main entry point — called when a Run button is clicked
*/
export function run(btn) {
const code = btn.getAttribute('data-code');
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
const pre = btn.closest('pre');
if (!pre) return;
const codeEl = pre.querySelector('code');
const code = codeEl ? codeEl.textContent : btn.getAttribute('data-code');
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
const panel = getOrCreatePanel(pre);
if (!code || !code.trim()) {
+23 -7
View File
@@ -72,7 +72,12 @@ function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g
// ── Storage ───────────────────────────────────────────────────────────
function getRecents() {
try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); }
try {
const values = JSON.parse(localStorage.getItem(LS_RECENT) || '[]');
return Array.isArray(values)
? values.filter(value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)).slice(0, MAX_RECENT)
: [];
}
catch { return []; }
}
@@ -85,17 +90,28 @@ function addRecent(hex) {
}
// ── Suggestions based on current color (5 harmony swatches) ──────────
function computeSuggestions() {
function suggestionsForHsv(h, s, v) {
// Complement, analogous ±30°, split-complement (+150), tone shift
return [
{ hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' },
{ hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' },
{ hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' },
{ hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' },
{ hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' },
{ hex: hsvToHex(h + 180, s, v), label: 'Complement' },
{ hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
{ hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
{ hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
];
}
// ── Reusable helpers for hosts that surface swatches inline ──────────
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); }
export function getRecentColors() { return getRecents(); }
export function recordRecentColor(hex) { addRecent(hex); }
export function suggestColorsFor(hex) {
const { h, s, v } = hexToHsv(hex || '#000000');
return suggestionsForHsv(h, s, v);
}
// ── Popover build ─────────────────────────────────────────────────────
function buildPopover() {
const p = document.createElement('div');
+65
View File
@@ -0,0 +1,65 @@
function streamError(payload) {
const detail = payload?.data ?? payload;
const candidates = [detail?.message, detail?.text, detail?.error?.message,
detail?.error, payload?.message, payload?.text, payload?.error?.message,
payload?.error, typeof payload === 'string' ? payload : null];
const message = candidates.find(value => typeof value === 'string' && value.trim());
return new Error(message || 'Model request failed.');
}
/** Decode complete SSE frames, requiring the backend's terminal DONE event. */
export async function consumeCompareEvents(response, onEvent, onActivity) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let line = '', event = 'message', data = [], skipLF = false;
try {
while (true) {
const { done, value } = await reader.read();
if (!done) onActivity?.();
let chunk = decoder.decode(value, { stream: !done });
if (skipLF && chunk.length) {
if (chunk.startsWith('\n')) chunk = chunk.slice(1);
skipLF = false;
}
const separators = /\r\n|\r|\n/g;
let start = 0, separator;
while ((separator = separators.exec(chunk))) {
line += chunk.slice(start, separator.index);
start = separator.index + separator[0].length;
skipLF = separator[0] === '\r' && start === chunk.length;
if (!line) {
if (data.length || event === 'error') {
const raw = data.join('\n');
if (event !== 'error' && raw === '[DONE]') return;
let payload;
try { payload = JSON.parse(raw); }
catch (_) {
if (event === 'error') throw streamError(raw);
throw new Error('Invalid model stream data.');
}
if (event === 'error' || payload?.type === 'error') throw streamError(payload);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Invalid model stream data.');
}
await onEvent(payload);
}
event = 'message';
data = [];
} else if (!line.startsWith(':')) {
const colon = line.indexOf(':');
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? '' : line.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'data') data.push(value);
else if (field === 'event') event = value || 'message';
}
line = '';
}
line += chunk.slice(start);
if (done) throw new Error('Stream ended before completion.');
}
} finally {
try { await reader.cancel(); } catch (_) {}
reader.releaseLock();
}
}
+12 -22
View File
@@ -1,5 +1,6 @@
// compare/stream.js — SSE streaming to panes
import state from './state.js';
import { consumeCompareEvents } from './sse.js';
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1';
import markdownModule from '../markdown.js';
@@ -472,25 +473,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
_resetIdleTimeout(); // any chunk = stream is alive
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
await consumeCompareEvents(response,
(json) => {
try {
const json = JSON.parse(data);
if (json.type === 'metrics') {
metrics = json.data;
@@ -672,7 +657,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
if (hist) hist.scrollTop = hist.scrollHeight;
} else if (json.delta) {
// Skip text deltas if we already rendered an image
if (aiMsgEl._imageData) continue;
if (aiMsgEl._imageData) return;
// Capture TTFT on very first text delta
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
// On first delta, destroy spinner and prepare text area
@@ -701,8 +686,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
_scheduleLiveRender(target);
}
} catch (e) { console.warn('Compare stream render error:', e); }
}
}
}, _resetIdleTimeout);
streamOk = true;
// Destroy any remaining spinner
@@ -900,9 +884,15 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
} else {
console.error('Compare stream error:', error);
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(error.message) + '</span>';
awaitingChoice = false;
const message = state._blindMode ? 'Model request failed. Try again.' : error.message;
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(message) + '</span>';
}
} finally {
if (aiMsgEl._spinner) {
aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
}
clearTimeout(timeoutId);
_timerDone = true;
cancelAnimationFrame(_rafId);
+6 -5
View File
@@ -1195,11 +1195,12 @@ async function _fetchDependencies() {
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
// append a caret button that toggles a per-row recipe panel below.
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
// Standalone recipe-caret button removed — the "Pick install
// command" action lives inside the Installed ▾ dropdown menu
// (see _showDepMenu) so each row only has ONE caret to click.
// Kept the variable so downstream concat code stays the same.
const recipeCaret = '';
// The caret is the only way to open the recipe panel (the Installed ▾
// menu has no recipe item), and the HW Fit / serve-card deep links
// open it by clicking this caret (#6747).
const recipeCaret = hasRecipe
? `<button class="cookbook-dep-tag cookbook-dep-recipe-caret" data-dep-recipe-toggle="${esc(pkg.name)}" title="Pick a model to see the exact install commands" aria-expanded="false" style="background:none;border:1px solid var(--border);padding:2px 6px;display:inline-flex;align-items:center;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg></button>`
: '';
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
// When llama_cpp (or any future engine) reports build_deps_missing
// from its system_prereqs probe, surface a one-tap install button
+2 -1
View File
@@ -1498,7 +1498,8 @@ function _rerenderCachedModels() {
const _ggufOptions = _ggufChoices.map(f =>
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
).join('');
const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
const _minimaxM3Snapshot = '~/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b';
const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
const _modelPathValue = _savedModelPath || _defaultServeModel;
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
+44 -7
View File
@@ -1996,6 +1996,17 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Active drop mode for the PDF toolbar — toolbar buttons set this; the
// next click on a page consumes it. null means clicks do nothing.
let _pdfDropMode = null;
let _pdfAnnotationDragging = false;
function _setPdfAnnotationDragging(dragging) {
_pdfAnnotationDragging = dragging;
for (const ref of _pdfPaneAnnotationsByDoc.get(activeDocId) || []) {
ref._refreshHandles?.();
if (dragging) {
const menu = ref.wrap?.querySelector('.pdf-annotation-text-menu');
if (menu) menu.style.display = 'none';
}
}
}
// Per-doc last-used line spacing for text annotations. Once the user picks
// 1.6 for one box, every text box dropped after that defaults to 1.6.
const _pdfLastLineHeight = new Map(); // docId -> number
@@ -2290,8 +2301,16 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// active (toolbar buttons set the mode). Without a mode, clicking does
// nothing — keeps page interactions predictable so users don't get
// surprise boxes from stray clicks.
const isBlankPageTarget = target => target === pageWrap
|| (target?.tagName === 'IMG' && target.parentElement === pageWrap);
let placementStartedOnPage = false;
pageWrap.addEventListener('pointerdown', ev => {
placementStartedOnPage = isBlankPageTarget(ev.target);
}, true);
pageWrap.addEventListener('click', (ev) => {
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return;
const canPlace = placementStartedOnPage;
placementStartedOnPage = false;
if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return;
if (!_pdfDropMode) return;
const rect = pageWrap.getBoundingClientRect();
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
@@ -2423,9 +2442,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Set handle visibility together; clicking/tapping the annotation itself
// brings hidden controls back.
let handlesRequested = _isTouch;
const _setHandlesVisible = (show) => {
if (!_pdfAnnotationDragging) handlesRequested = show;
const dismissed = wrap.dataset.controlsDismissed === '1';
const v = (show && !dismissed) ? '' : 'none';
const v = (show && !dismissed && !_pdfAnnotationDragging) ? '' : 'none';
del.style.display = v;
grip.style.display = v;
resize.style.display = v;
@@ -2446,7 +2467,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
_setHandlesVisible(true);
});
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible };
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible,
_refreshHandles: () => _setHandlesVisible(handlesRequested) };
if (kind === 'check') {
// Stamp checkmark — value is fixed, nothing to listen for.
@@ -2568,7 +2590,12 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ref._autoGrow = _autoGrow;
}
del.addEventListener('pointerdown', ev => {
ev.preventDefault();
ev.stopPropagation();
});
del.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_pushPdfUndoSnapshot();
_removeAnnotation(ref);
@@ -2580,6 +2607,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ev.stopPropagation();
_pushPdfUndoSnapshot();
try { grip.setPointerCapture(ev.pointerId); } catch (_) {}
_setPdfAnnotationDragging(true);
// Hide the × and resize handles while moving so they don't obscure the
// box — easier to see exactly where it lands. Restored on release.
del.style.display = 'none';
@@ -2598,11 +2626,14 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
_setPdfAnnotationDragging(false);
_setHandlesVisible(true);
_schedulePdfPaneSave();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
});
// Drag bottom-right corner to resize. Width/height stored as percentages.
@@ -2611,6 +2642,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ev.stopPropagation();
_pushPdfUndoSnapshot();
try { resize.setPointerCapture(ev.pointerId); } catch (_) {}
_setPdfAnnotationDragging(true);
// Hide the × and move handles while resizing — clean view of the box edge.
del.style.display = 'none';
grip.style.display = 'none';
@@ -2628,11 +2660,14 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
_setPdfAnnotationDragging(false);
_setHandlesVisible(true);
_schedulePdfPaneSave();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
});
// Text options menu — opened from the floating … button so the spacing
@@ -5197,7 +5232,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
async function _handleAttachUpload(e) {
const files = e.target.files;
const files = Array.from(e.target.files || []);
e.target.value = ''; // reset for next upload
await _uploadComposeFiles(files);
}
@@ -5928,7 +5963,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
async function _handleMarkdownImageUpload(e) {
const files = e.target.files;
const files = Array.from(e.target.files || []);
e.target.value = '';
await _uploadMarkdownImages(files);
}
@@ -15239,7 +15274,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
menu.style.top = (rect.bottom + 2) + 'px';
menu.style.right = (window.innerWidth - rect.right) + 'px';
menu.style.left = 'auto';
menu.style.zIndex = '9999';
const editorPane = document.querySelector('.doc-editor-pane');
menu.style.zIndex = String(Math.max(topPortalZ(), (parseInt(getComputedStyle(editorPane).zIndex, 10) || 0) + 1));
const langLabel = lang ? lang.toUpperCase() : 'TXT';
// Form-backed markdown doc → primary export is the filled PDF, not the
@@ -15314,7 +15350,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
// Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual node removal.
const close = bindMenuDismiss(menu, () => { menu.remove(); });
const exportBtn = document.getElementById('doc-footer-export-btn');
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target) && !exportBtn?.contains(ev.target));
}
function _richTextExportCss() {
+33 -1
View File
@@ -31,10 +31,15 @@ export function enable(containerId, itemSelector, options = {}) {
onReorder: options.onReorder || null,
handleSelector: options.handleSelector || null,
excludeSelector: options.excludeSelector || null,
ignoreSelector: options.ignoreSelector || null,
// When > 0 the pointer must travel this many pixels before the drag
// starts, so rows that also handle plain clicks stay clickable.
threshold: Math.max(0, Number(options.threshold) || 0),
storageKey: options.storageKey || null,
};
let draggedEl = null;
let pending = null;
let placeholder = null;
let offsetY = 0;
let items = [];
@@ -130,6 +135,12 @@ export function enable(containerId, itemSelector, options = {}) {
draggedEl.style.top = snapTop + 'px';
setTimeout(() => {
// The list may have been re-rendered mid-drag (e.g. dropping a layer
// effect copies it and rebuilds the panel), detaching both nodes.
if (!placeholder.parentNode || !draggedEl.isConnected) {
placeholder.remove();
return;
}
placeholder.parentNode.insertBefore(draggedEl, placeholder);
placeholder.remove();
draggedEl.classList.remove('dragging');
@@ -158,10 +169,21 @@ export function enable(containerId, itemSelector, options = {}) {
function onMouseDown(e) {
if (e.button !== 0) return;
if (config.handleSelector && !e.target.closest(config.handleSelector)) return;
if (config.ignoreSelector && e.target.closest(config.ignoreSelector)) return;
const item = e.target.closest(itemSelector);
if (!item || !container.contains(item)) return;
if (config.excludeSelector && item.matches(config.excludeSelector)) return;
if (config.threshold > 0) {
// Defer the lift until the pointer actually moves, so a plain click
// (select layer, open the rename field) is left untouched.
pending = { item, startY: e.clientY };
e.preventDefault();
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return;
}
e.preventDefault();
e.stopPropagation();
startDrag(e.clientY, item);
@@ -169,11 +191,21 @@ export function enable(containerId, itemSelector, options = {}) {
document.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e) { moveDrag(e.clientY); }
function onMouseMove(e) {
if (pending) {
if (Math.abs(e.clientY - pending.startY) < config.threshold) return;
const { item, startY } = pending;
pending = null;
e.preventDefault();
startDrag(startY, item);
}
moveDrag(e.clientY);
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
pending = null;
endDrag();
}
+243
View File
@@ -0,0 +1,243 @@
/**
* Actions panel (PhotoCraft parity, Phase 11) — a lightweight macro
* recorder/player. Every step maps to a known editor command by CSS
* selector; recording captures clicks on those selectors, and play
* replays them in order. Named actions persist to localStorage.
*
* The module owns no editor state: it only dispatches clicks on the
* selectors it is given.
*/
export const ACTION_COMMANDS = Object.freeze([
['flip-h', 'Flip Horizontal', '[data-image-action="flip-h"]'],
['flip-v', 'Flip Vertical', '[data-image-action="flip-v"]'],
['rotate-90', 'Rotate 90° CW', '[data-image-action="rotate-90"]'],
['rotate-180', 'Rotate 180°', '[data-image-action="rotate-180"]'],
['fill', 'Fill Foreground', '[data-image-action="fill"]'],
['merge-down', 'Merge Down', '#ge-merge-down'],
['flatten', 'Flatten', '#ge-flatten'],
['select-all', 'Select All', '[data-selection-action="all"]'],
['deselect', 'Deselect', '[data-selection-action="deselect"]'],
['invert-selection', 'Invert Selection', '[data-selection-action="invert"]'],
['blur-gaussian', 'Gaussian Blur', '[data-filter-action="blur-gaussian"]'],
['blur-motion', 'Motion Blur', '[data-filter-action="blur-motion"]'],
['noise-add', 'Add Noise', '[data-filter-action="noise-add"]'],
['pixelate-mosaic', 'Pixelate', '[data-filter-action="pixelate-mosaic"]'],
['sharpen', 'Sharpen', '[data-filter-action="sharpen"]'],
['stylize-emboss', 'Emboss', '[data-filter-action="stylize-emboss"]'],
['distort-twirl', 'Twirl', '[data-filter-action="distort-twirl"]'],
['rembg', 'Remove Background', '[data-tool-option="rembg"]'],
['inpaint', 'Inpaint', '[data-tool="inpaint"]'],
]);
const STORAGE_KEY = 'ge-actions';
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
function commandById(id) {
return ACTION_COMMANDS.find(command => command[0] === id) || null;
}
export function createActionsPanel({ showToast = () => {}, getLayers = null, selectLayer = null } = {}) {
let steps = [];
let recording = false;
let saved = loadSaved();
const body = el('div', 'ge-panel-body ge-actions-body');
const nameRow = el('div', 'ge-actions-name-row');
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.placeholder = 'Action name';
nameInput.className = 'ge-actions-name';
const saveButton = el('button', 'ge-btn ge-btn-sm', 'Save');
saveButton.type = 'button';
nameRow.append(nameInput, saveButton);
const addRow = el('div', 'ge-actions-add-row');
const addSelect = document.createElement('select');
addSelect.className = 'ge-actions-command';
ACTION_COMMANDS.forEach(([id, label]) => addSelect.appendChild(new Option(label, id)));
const addButton = el('button', 'ge-btn ge-btn-sm', 'Add');
addButton.type = 'button';
addRow.append(addSelect, addButton);
const controls = el('div', 'ge-actions-controls');
const recordButton = el('button', 'ge-btn ge-btn-sm', 'Record');
recordButton.type = 'button';
const playButton = el('button', 'ge-btn ge-btn-sm', 'Play');
playButton.type = 'button';
const clearButton = el('button', 'ge-btn ge-btn-sm', 'Clear');
clearButton.type = 'button';
controls.append(recordButton, playButton, clearButton);
const batchRow = el('div', 'ge-actions-batch-row');
const batchSelect = document.createElement('select');
batchSelect.className = 'ge-actions-batch-select';
const batchButton = el('button', 'ge-btn ge-btn-sm', 'Batch');
batchButton.type = 'button';
batchButton.title = 'Apply the selected action to every layer';
const batchDocsButton = el('button', 'ge-btn ge-btn-sm', 'Batch docs');
batchDocsButton.type = 'button';
batchDocsButton.title = 'Apply the selected action to every open document';
batchRow.append(batchSelect, batchButton, batchDocsButton);
const list = el('div', 'ge-actions-list');
const savedList = el('div', 'ge-actions-saved');
body.append(nameRow, addRow, controls, batchRow, list, savedList);
function renderList() {
list.innerHTML = '';
if (!steps.length) {
list.appendChild(el('p', 'ge-panel-empty', 'No steps. Add commands or record.'));
return;
}
steps.forEach((step, index) => {
const command = commandById(step);
const row = el('div', 'ge-actions-step');
row.append(
el('span', 'ge-actions-step-index', String(index + 1)),
el('span', 'ge-actions-step-label', command ? command[1] : step),
);
const remove = el('button', 'ge-action-remove', '✕');
remove.type = 'button';
remove.addEventListener('click', () => { steps.splice(index, 1); renderList(); });
row.appendChild(remove);
list.appendChild(row);
});
}
function renderSaved() {
savedList.innerHTML = '';
syncBatchSelect();
const names = Object.keys(saved);
if (!names.length) return;
savedList.appendChild(el('div', 'ge-panel-title', 'SAVED'));
names.forEach(name => {
const row = el('div', 'ge-actions-saved-row');
const loadButton = el('button', 'ge-btn ge-btn-sm', name);
loadButton.type = 'button';
loadButton.addEventListener('click', () => { steps = saved[name].slice(); renderList(); });
const deleteButton = el('button', 'ge-action-remove', '✕');
deleteButton.type = 'button';
deleteButton.addEventListener('click', () => { delete saved[name]; persist(); renderSaved(); });
row.append(loadButton, deleteButton);
savedList.appendChild(row);
});
}
function persist() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); } catch { /* ignore */ }
}
function syncBatchSelect() {
const names = Object.keys(saved);
const previous = batchSelect.value;
batchSelect.innerHTML = '';
if (!names.length) {
batchSelect.appendChild(new Option('No saved actions', ''));
batchButton.disabled = true;
batchDocsButton.disabled = true;
return;
}
batchButton.disabled = false;
batchDocsButton.disabled = false;
names.forEach(name => batchSelect.appendChild(new Option(name, name)));
if (names.includes(previous)) batchSelect.value = previous;
}
function loadSaved() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch { return {}; }
}
function runStep(step) {
const command = commandById(step);
if (!command) return;
document.querySelector(command[2])?.click();
}
function play() {
steps.forEach((step, index) => setTimeout(() => runStep(step), index * 60));
showToast(`Playing ${steps.length} action${steps.length === 1 ? '' : 's'}`);
}
async function runOnAllLayers() {
const name = batchSelect.value;
const action = name && saved[name] ? saved[name].slice() : null;
if (!action || !action.length) { showToast('Select a saved action to batch'); return; }
if (!getLayers || !selectLayer) { showToast('Batch is unavailable'); return; }
const layers = getLayers() || [];
if (layers.length < 1) { showToast('No layers to batch'); return; }
showToast(`Batching "${name}" over ${layers.length} layer${layers.length === 1 ? '' : 's'}`);
for (const layer of layers) {
selectLayer(layer.id);
for (const step of action) {
runStep(step);
await new Promise(resolve => setTimeout(resolve, 80));
}
}
showToast('Batch complete');
}
function onDocumentClick(event) {
if (!recording) return;
const match = ACTION_COMMANDS.find(command => event.target.closest?.(command[2]));
if (match && !steps.includes(match[0])) {
steps.push(match[0]);
renderList();
}
}
function toggleRecord() {
recording = !recording;
recordButton.classList.toggle('active', recording);
recordButton.textContent = recording ? 'Stop' : 'Record';
showToast(recording ? 'Recording actions…' : 'Recording stopped');
}
addButton.addEventListener('click', () => { steps.push(addSelect.value); renderList(); });
recordButton.addEventListener('click', toggleRecord);
playButton.addEventListener('click', play);
batchButton.addEventListener('click', () => { runOnAllLayers(); });
batchDocsButton.addEventListener('click', async () => {
const name = batchSelect.value;
if (!name) { showToast('Select a saved action to batch'); return; }
try {
const response = await window.geEditorControl?.invoke?.('actions.batchDocuments', { name });
const results = (response && response.result) || [];
const ok = results.filter((entry) => entry.ok).length;
showToast(`Batch docs: ${ok}/${results.length} document${results.length === 1 ? '' : 's'}`);
} catch (error) {
showToast('Batch docs failed: ' + (error?.message || error));
}
});
clearButton.addEventListener('click', () => { steps = []; renderList(); });
saveButton.addEventListener('click', () => {
const name = (nameInput.value || '').trim();
if (!name) return;
saved[name] = steps.slice();
persist();
renderSaved();
showToast(`Saved action "${name}"`);
});
document.addEventListener('click', onDocumentClick, true);
const element = el('div', 'ge-panel');
element.id = 'ge-panel-actions';
element.appendChild(el('div', 'ge-panel-title', 'ACTIONS'));
element.appendChild(body);
renderList();
renderSaved();
return { element, play, run: runStep, runOnAllLayers, getSteps: () => steps.slice(), setSteps: (next) => { steps = next.slice(); renderList(); } };
}
+17 -10
View File
@@ -2,10 +2,12 @@
import { applyAdjustment } from './fx/pixel-pass.js';
import { defaultAdjParams } from './layer-helpers.js';
import { renderWithLayerMasks } from './composite-helpers.js';
import { blendDraw } from './blend-modes.js';
const TYPES = new Set([
'brightness-contrast',
'exposure',
'develop',
'white-balance',
'hue-saturation',
'vibrance',
@@ -16,6 +18,12 @@ const TYPES = new Set([
'color-balance',
'selective-color',
'gradient-map',
'invert',
'posterize',
'threshold',
'photo-filter',
'channel-mixer',
'color-lookup',
]);
function clone(value) {
@@ -52,6 +60,11 @@ export function normalizeAdjustmentData(value) {
params.ranges[name] = { ...clone(defaults.ranges[name]), ...(params.ranges[name] || {}) };
}
}
if (type === 'channel-mixer') {
for (const name of ['red', 'green', 'blue', 'constant']) {
params[name] = { ...clone(defaults[name]), ...(params[name] || {}) };
}
}
return { type, params };
}
@@ -109,11 +122,8 @@ export function drawAdjustmentLayer(target, editorState, layer, clippingBase, re
adjusted = clipped;
}
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(adjusted, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1;
blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0);
return true;
}
@@ -154,11 +164,8 @@ export async function drawAdjustmentLayerAsync(target, editorState, layer, clipp
clippedCtx.globalCompositeOperation = 'source-over';
adjusted = clipped;
}
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(adjusted, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1;
blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0);
return true;
}
+255 -80
View File
@@ -27,6 +27,12 @@
import { state } from './state.js';
import { sortModelIds } from '../modelSort.js';
// Last result of loadAIModels() — consumed by the editor setup checklist
// (static/js/editor/preflight.js) to decide whether an image provider is
// reachable and which image models are available.
let _aiModelsStatus = { ready: false, models: [], error: null, at: 0 };
export function getAIModelsStatus() { return _aiModelsStatus; }
export function resolveInpaintModel(select) {
const options = Array.from(select?.options || []).filter(option =>
!option.disabled && option.value && option.value !== '__serve_cookbook__');
@@ -34,6 +40,70 @@ export function resolveInpaintModel(select) {
return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null;
}
// Resolve the model the Quick Edit box will use. Prefers the explicit Quick
// Edit picker (#ge-ai-command-model); falls back to the inpaint picker / Auto.
export function resolveQuickEditModel() {
const sel = document.getElementById('ge-ai-command-model');
if (sel && sel.value && sel.value !== '__serve_cookbook__') {
const opt = sel.options[sel.selectedIndex];
const raw = sel.value || '';
const idx = raw.indexOf('::');
return {
endpoint: idx < 0 ? raw : raw.slice(0, idx),
model: idx < 0 ? '' : raw.slice(idx + 2),
label: ((opt && opt.textContent) || '').trim(),
};
}
const inp = document.getElementById('ge-ai-inpaint');
const opt = resolveInpaintModel(inp);
if (!opt) return { endpoint: '', model: '', label: '' };
const raw = opt.value || '';
const idx = raw.indexOf('::');
return {
endpoint: idx < 0 ? raw : raw.slice(0, idx),
model: idx < 0 ? '' : raw.slice(idx + 2),
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')),
};
}
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
// that reads the canvas screenshot and plans the edit. Default is the local
// Qwen3.5-9B VL server on seneca; "Server default" keeps the HARNESS planner.
export function resolveQuickEditAgentModel() {
const sel = document.getElementById('ge-ai-command-agent-model');
const raw = (sel && sel.value) || '';
if (!raw || raw === 'default') {
return { agent: '', endpoint: '', label: 'Server default' };
}
const idx = raw.indexOf('::');
const endpoint = idx < 0 ? '' : raw.slice(0, idx);
const agent = idx < 0 ? raw : raw.slice(idx + 2);
const opt = sel.options[sel.selectedIndex];
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
}
// Quick Edit agent (planner) model options. Kept small + explicit;
// dynamically populated from /api/editor/agent-models.
export const QUICK_EDIT_AGENT_OPTIONS = [
{ value: 'default', label: 'Server default (hosted planner)' },
];
// can see which model Quick Edit / inpaint will call.
export function updateFooterEditModel() {
const el = document.getElementById('ge-status-model');
if (!el) return;
const info = resolveQuickEditModel();
if (!info.label && !info.model) {
el.textContent = 'Edit: none';
el.title = 'No AI edit model available';
return;
}
const label = info.label || info.model;
el.textContent = `Edit: ${label}`;
el.title = `AI edit model: ${label}`;
}
// Heuristic classifier on a model id + endpoint name. A model can be:
// - gen: text-to-image generation
// - inpaint: image+mask edit (inpaint / img2img)
@@ -70,12 +140,41 @@ export function modelCaps(modelId, endpointName, endpointType) {
return { gen: false, inpaint: false };
}
export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) {
// Global listeners resolve live controls instead of retaining editor instances.
function syncCommandModel(value) {
const select = document.getElementById('ge-ai-command-model');
if (!select || !select._modelsReady) return;
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
value = '';
}
if (select.value !== value) {
select.value = value;
if (select.value !== value) select.value = '';
updateFooterEditModel();
}
}
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
window.addEventListener('storage', e => {
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
});
window.addEventListener('ge:model-endpoints-updated', e => {
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
});
export function wireAIModelSelectors({ container, apiBase = '', openCookbookForImg2img = () => {} } = {}) {
const root = container || document;
root._aiModelListeners?.abort();
const listeners = new AbortController();
root._aiModelListeners = listeners;
const listen = (target, type, handler, options = {}) => target?.addEventListener(type, handler, {
...(typeof options === 'boolean' ? { capture: options } : options), signal: listeners.signal,
});
// Delegated handler for the "+ Serve a model in Cookbook…" sentinel
// option — catches clicks regardless of whether loadAIModels has
// rewired the individual select yet and survives any innerHTML
// reset later.
container.addEventListener('change', (e) => {
listen(root, 'change', (e) => {
const sel = e.target.closest('select');
if (!sel) return;
if (sel.value !== '__serve_cookbook__') return;
@@ -85,79 +184,133 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
openCookbookForImg2img();
});
// Track prior value so we can restore it after the sentinel fires.
container.addEventListener('focus', (e) => {
listen(root, 'focus', (e) => {
const sel = e.target.closest('select');
if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value;
}, true);
const aiGenSelect = document.getElementById('ge-ai-model');
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
const aiCommandSelect = document.getElementById('ge-ai-command-model');
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
if (aiInpaintSelect) listen(aiInpaintSelect, 'change', () => updateFooterEditModel());
// The Quick Edit agent (planner) picker: dynamically populates from
// /api/editor/agent-models; restore/save choice from localStorage.
const populateAgentSelect = async () => {
if (!aiCommandAgentSelect) return;
const prevVal = aiCommandAgentSelect.value;
let agentOptions = [...QUICK_EDIT_AGENT_OPTIONS];
try {
const res = await fetch(`${apiBase}/api/editor/agent-models`);
if (res.ok) {
const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return;
const serverModels = data.models || [];
for (const m of serverModels) {
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : '')}::${m.id}`;
const label = m.label || m.id;
if (!agentOptions.some(o => o.value === optVal)) {
agentOptions.push({ value: optVal, label });
}
}
}
} catch (_) {}
aiCommandAgentSelect.innerHTML = '';
for (const opt of agentOptions) {
const el = document.createElement('option');
el.value = opt.value;
el.textContent = opt.label;
aiCommandAgentSelect.appendChild(el);
}
try {
const saved = localStorage.getItem('ge-ai-command-agent-model');
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
aiCommandAgentSelect.value = saved;
} else if (prevVal && [...aiCommandAgentSelect.options].some(o => o.value === prevVal)) {
aiCommandAgentSelect.value = prevVal;
}
} catch {}
if (aiCommandAgentSelect._agentWired !== listeners) {
aiCommandAgentSelect._agentWired = listeners;
listen(aiCommandAgentSelect, 'change', () => {
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
});
}
};
// The global Gen model dropdown was removed from the editor topbar;
// only bail if there's nothing to populate at all (neither the Gen
// select nor the inpaint select nor any per-tool select).
if (!aiGenSelect && !aiInpaintSelect &&
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
!document.querySelector('select.ge-tool-model')) return;
void populateAgentSelect();
async function loadAIModels(opts = {}) {
try {
const selectBaseUrl = opts.selectBaseUrl || '';
const prevGenValue = aiGenSelect?.value || '';
const prevInpaintValue = aiInpaintSelect?.value || '';
const res = await fetch(`${apiBase}/api/model-endpoints`);
const res = await fetch(`${apiBase}/api/editor/image-models`);
if (listeners.signal.aborted || !root.isConnected) return;
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
const endpoints = await res.json();
const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return;
const modelsList = data.models || [];
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
let firstGen = null;
let selectedGen = null;
let selectedInpaint = null;
for (const ep of endpoints) {
if (!ep.is_enabled) continue;
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
const models = hasListedModels ? sortModelIds(ep.models) : [''];
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image';
// Image/inpaint endpoints can be called by URL even when their
// /models cache is still empty, so don't strand a freshly served
// Cookbook model as "(offline)" in the editor picker.
const epUsable = !!ep.online || isImageEndpoint;
for (const modelId of models) {
const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type);
if (!caps.gen && !caps.inpaint) continue;
// Encode "<base_url>::<model_id>" so the value carries both pieces.
const value = `${ep.base_url}::${modelId}`;
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url);
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : '';
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`;
if (caps.gen && aiGenSelect) {
const loaded = [];
for (const m of modelsList) {
const modelId = m.id || '';
const caps = modelCaps(modelId, m.provider, m.kind);
const canGen = Boolean(caps.gen || m.kind === 'generate');
const canInpaint = Boolean(caps.inpaint || m.kind === 'edit');
if (!canGen && !canInpaint) continue;
// Use opaque endpoint_id if available; never fall back to raw base_url
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : '');
const value = endpointKey ? `${endpointKey}::${modelId}` : modelId;
const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider);
const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : '';
const label = `${shortModel}${epHint}`;
const epUsable = true;
loaded.push({ value, label, gen: canGen, inpaint: canInpaint, usable: epUsable, endpoint: endpointKey, model: modelId });
if (canGen && aiGenSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiGenSelect.appendChild(opt);
if (epUsable && !firstGen) firstGen = value;
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedGen) selectedGen = value;
}
if (canInpaint && aiInpaintSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiInpaintSelect.appendChild(opt);
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedInpaint) selectedInpaint = value;
}
if (canInpaint || canGen) {
for (const ts of perToolSelects) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiGenSelect.appendChild(opt);
if (epUsable && !firstGen) firstGen = value;
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value;
ts.appendChild(opt);
}
if (caps.inpaint && aiInpaintSelect) {
if (aiCommandSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiInpaintSelect.appendChild(opt);
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
}
// Per-tool selectors get every img2img-capable entry. Both
// caps.inpaint AND caps.gen models work for harmonize /
// style / upscale (anything that can do img2img).
if (caps.inpaint || caps.gen) {
for (const ts of perToolSelects) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
ts.appendChild(opt);
}
aiCommandSelect.appendChild(opt);
}
}
}
@@ -173,6 +326,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' });
aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)';
}
updateFooterEditModel();
// Append the "Serve a model in Cookbook…" sentinel at the
// bottom of every model dropdown.
const appendServeSentinel = (sel) => {
@@ -188,21 +342,52 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
for (const ts of perToolSelects) appendServeSentinel(ts);
if (aiGenSelect) appendServeSentinel(aiGenSelect);
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
// Wire the sentinel on the Gen + Inpaint selects too.
const wireServeSentinel = (sel) => {
if (!sel) return;
let prev = sel.value;
sel.addEventListener('change', () => {
if (sel.value === '__serve_cookbook__') {
sel.value = prev;
openCookbookForImg2img();
return;
if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
// Sentinel changes are handled by the component's delegated listener.
if (aiCommandSelect) {
aiCommandSelect._modelsReady = true;
try {
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
let saved = rawSaved;
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
const matching = loaded.filter(l => l.model === modelPart);
if (matching.length === 1) {
saved = matching[0].value;
} else {
saved = '';
}
try {
if (saved) localStorage.setItem('ge-ai-command-model', saved);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
}
prev = sel.value;
});
};
wireServeSentinel(aiGenSelect);
wireServeSentinel(aiInpaintSelect);
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
aiCommandSelect.value = saved;
} else {
aiCommandSelect.value = '';
}
if (aiCommandSelect.value !== rawSaved) {
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
}
} catch {}
if (aiCommandSelect._commandWired !== listeners) {
aiCommandSelect._commandWired = listeners;
listen(aiCommandSelect, 'change', () => {
if (aiCommandSelect.value === '__serve_cookbook__') return;
const val = aiCommandSelect.value || '';
try {
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
updateFooterEditModel();
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
});
}
}
// Restore each per-tool selection from localStorage.
for (const ts of perToolSelects) {
const key = 'ge-tool-model-' + ts.dataset.geToolModel;
@@ -213,7 +398,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
}
} catch {}
let prevValue = ts.value;
ts.addEventListener('change', () => {
listen(ts, 'change', () => {
if (ts.value === '__serve_cookbook__') {
ts.value = prevValue;
openCookbookForImg2img();
@@ -223,40 +408,30 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
try { localStorage.setItem(key, ts.value); } catch {}
});
}
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
} catch (e) {
if (listeners.signal.aborted || !root.isConnected) return;
// Fetch failed — still give the user the affordance to set up
// a model. Otherwise the dropdown shows only "Auto" with no
// hint about what to do next.
const fallback = '<option value="">Auto</option><option disabled>Could not load image models</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
if (aiGenSelect) aiGenSelect.innerHTML = fallback;
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
if (aiCommandSelect) aiCommandSelect.innerHTML = fallback;
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
const wireServe = (sel) => {
if (!sel) return;
let prev = sel.value;
sel.addEventListener('change', () => {
if (sel.value === '__serve_cookbook__') {
sel.value = prev;
openCookbookForImg2img();
return;
}
prev = sel.value;
});
};
wireServe(aiGenSelect);
wireServe(aiInpaintSelect);
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
void populateAgentSelect();
updateFooterEditModel();
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
}
}
loadAIModels();
const onModelEndpointsUpdated = (e) => {
if (!container.isConnected) {
window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
return;
}
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
if (aiCommandSelect) aiCommandSelect._refreshAIModels = baseUrl => {
if (listeners.signal.aborted || !root.isConnected) return;
void loadAIModels({ selectBaseUrl: baseUrl });
void populateAgentSelect();
};
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
// Re-fetch the model list when the user opens the inpaint dropdown,
// so a model served via Cookbook mid-edit shows up without having to
// close and reopen the editor. Debounced to one refresh per 3s so
@@ -275,5 +450,5 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
if ([...sel.options].some(o => o.value === keep)) sel.value = keep;
});
};
container.addEventListener('mousedown', refreshOnOpen, true);
listen(root, 'mousedown', refreshOnOpen, true);
}
+85
View File
@@ -0,0 +1,85 @@
/**
* AI settings dialog.
*
* Holds the image-edit model, the AI agent (vision) model, and the opt-in
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
*/
export function createAiSettings({ container }) {
const el = document.createElement('div');
el.className = 'ge-ai-settings';
el.hidden = true;
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-label', 'AI settings');
el.innerHTML = `
<div class="ge-ai-settings-card">
<div class="ge-ai-settings-head">
<h3>AI settings</h3>
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
</div>
<div class="ge-ai-settings-body">
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
<div class="ge-ai-settings-strength">
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
<span class="ge-ai-settings-strength-val" id="ge-look-strength-val">1.4×</span>
</div>
<p class="ge-ai-settings-note">How strongly named looks (Kodachrome, teal and orange, …) are graded.</p>
<label class="ge-ai-settings-opt" title="Ask the vision model to review the result and retry on a clear failure">
<input type="checkbox" id="ge-ai-command-selfcheck" /> Double-check AI edits
</label>
</div>
<div class="ge-ai-settings-foot">
<button type="button" class="ge-ai-settings-done" data-ai-settings-close>Done</button>
</div>
</div>`;
function open(focusId) {
el.hidden = false;
if (focusId) {
const target = document.getElementById(focusId);
if (target) {
try { target.focus({ preventScroll: true }); } catch (_) { target.focus(); }
}
}
}
function close() { el.hidden = true; }
el.addEventListener('click', (e) => {
if (e.target === el || e.target.closest('[data-ai-settings-close]')) {
e.preventDefault();
close();
}
});
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); };
document.addEventListener('keydown', onKey);
container.appendChild(el);
const strengthInput = el.querySelector('#ge-look-strength');
const strengthVal = el.querySelector('#ge-look-strength-val');
if (strengthInput) {
try {
const saved = parseFloat(localStorage.getItem('ge-look-strength'));
if (Number.isFinite(saved) && saved > 0) strengthInput.value = String(saved);
} catch (_) {}
if (strengthVal) strengthVal.textContent = Number(strengthInput.value).toFixed(1) + '×';
strengthInput.addEventListener('input', () => {
const v = Number(strengthInput.value);
if (strengthVal) strengthVal.textContent = v.toFixed(1) + '×';
try { localStorage.setItem('ge-look-strength', String(v)); } catch (_) {}
});
}
function destroy() {
document.removeEventListener('keydown', onKey);
el.remove();
}
return { element: el, open, close, destroy };
}
+32 -11
View File
@@ -215,21 +215,19 @@ export function wireAIToolsMisc({
function addEmptyLayer() {
saveState('Add layer');
const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight);
state.layers.push(layer);
// Photoshop inserts the new layer directly above the selected one.
const activeIndex = state.layers.findIndex(item => item.id === state.activeLayerId);
const insertIndex = activeIndex >= 0 ? activeIndex + 1 : state.layers.length;
state.layers.splice(insertIndex, 0, layer);
state.activeLayerId = layer.id;
renderLayerPanel();
composite();
}
const addButton = document.getElementById('ge-add-layer');
addButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const buildFxMenu = (anchor) => {
document.querySelector('.ge-add-layer-menu')?.remove();
const menu = document.createElement('div');
menu.className = 'ge-add-layer-menu ge-frosted';
menu.innerHTML = `
<button type="button" data-layer-kind="raster"><span class="ge-add-layer-symbol">+</span><span>Pixel Layer</span></button>
<span class="ge-add-layer-divider"></span>
<button type="button" data-adjustment-type="levels"><span class="ge-add-layer-symbol">▥</span><span>Levels</span></button>
<button type="button" data-adjustment-type="curves"><span class="ge-add-layer-symbol">⌁</span><span>Curves</span></button>
<button type="button" data-adjustment-type="exposure"><span class="ge-add-layer-symbol">☼</span><span>Exposure</span></button>
@@ -242,9 +240,16 @@ export function wireAIToolsMisc({
<button type="button" data-adjustment-type="color-balance"><span class="ge-add-layer-symbol">◒</span><span>Color Balance</span></button>
<button type="button" data-adjustment-type="selective-color"><span class="ge-add-layer-symbol">◎</span><span>Selective Color</span></button>
<button type="button" data-adjustment-type="gradient-map"><span class="ge-add-layer-symbol">▰</span><span>Gradient Map</span></button>
<span class="ge-add-layer-divider"></span>
<button type="button" data-adjustment-type="invert"><span class="ge-add-layer-symbol">◑</span><span>Invert</span></button>
<button type="button" data-adjustment-type="posterize"><span class="ge-add-layer-symbol">▤</span><span>Posterize</span></button>
<button type="button" data-adjustment-type="threshold"><span class="ge-add-layer-symbol">◧</span><span>Threshold</span></button>
<button type="button" data-adjustment-type="photo-filter"><span class="ge-add-layer-symbol">◍</span><span>Photo Filter</span></button>
<button type="button" data-adjustment-type="channel-mixer"><span class="ge-add-layer-symbol">⇄</span><span>Channel Mixer</span></button>
<button type="button" data-adjustment-type="color-lookup"><span class="ge-add-layer-symbol">▦</span><span>Color Lookup</span></button>
`;
document.body.appendChild(menu);
const rect = addButton.getBoundingClientRect();
const rect = anchor.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`;
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`;
@@ -253,7 +258,7 @@ export function wireAIToolsMisc({
document.removeEventListener('pointerdown', away, true);
};
const away = pointerEvent => {
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close();
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== anchor) close();
};
requestAnimationFrame(() => document.addEventListener('pointerdown', away, true));
menu.addEventListener('click', clickEvent => {
@@ -261,10 +266,26 @@ export function wireAIToolsMisc({
if (!button) return;
clickEvent.preventDefault();
clickEvent.stopPropagation();
if (button.dataset.layerKind === 'raster') addEmptyLayer();
else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton);
if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, anchor);
close();
});
};
// "+" adds a plain pixel layer directly.
const addButton = document.getElementById('ge-add-layer');
addButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
document.querySelector('.ge-add-layer-menu')?.remove();
addEmptyLayer();
});
// "fx" opens the adjustment-layer chooser.
const addFxButton = document.getElementById('ge-add-fx');
addFxButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
buildFxMenu(addFxButton);
});
return { addEmptyLayer };
+35
View File
@@ -0,0 +1,35 @@
// Pure aspect-fitting geometry for the editor. When a whole-image resize is
// asked for a target aspect that differs from the current image, content must
// be cropped to that aspect (centred on a focus point) and then scaled
// UNIFORMLY — never stretched.
//
// aspectFitCropRect returns the document-space rectangle to crop FIRST so a
// subsequent uniform scale-to-{targetW,targetH} preserves the subject aspect
// ratio. Returns the full image unchanged when the aspect already matches.
export function aspectFitCropRect({ width, height, focusX, focusY, targetAspect, tolerance = 0.005 } = {}) {
const w = Number(width);
const h = Number(height);
const a = Number(targetAspect);
if (!(w > 0) || !(h > 0) || !(a > 0)) return null;
const curAspect = w / h;
if (Math.abs(curAspect - a) / a <= tolerance) return { x: 0, y: 0, width: Math.round(w), height: Math.round(h) };
const fx = Number.isFinite(Number(focusX)) ? Number(focusX) : w / 2;
const fy = Number.isFinite(Number(focusY)) ? Number(focusY) : h / 2;
let cw;
let ch;
if (curAspect > a) {
ch = h;
cw = Math.max(1, Math.round(ch * a));
} else {
cw = w;
ch = Math.max(1, Math.round(cw / a));
}
let x = Math.round(fx - cw / 2);
let y = Math.round(fy - ch / 2);
x = Math.max(0, Math.min(w - cw, x));
y = Math.max(0, Math.min(h - ch, y));
return { x, y, width: cw, height: ch };
}
export default { aspectFitCropRect };
+35
View File
@@ -0,0 +1,35 @@
/**
* Batch runner — a small, dependency-free orchestrator that applies one action
* to every item in a list (open documents), isolating each item so a failure on
* one never aborts the batch. Pure async control flow: the caller supplies
* `runOne`, so the same loop is unit-testable without an editor.
*/
/**
* @param {object} options
* @param {Array<{id:string,name?:string}>} options.documents
* @param {(doc:object, index:number)=>Promise<any>} options.runOne
* @param {number} [options.delayMs]
* @param {(done:number,total:number,doc:object)=>void} [options.onProgress]
* @returns {Promise<Array<{id,name,ok,output?,error?}>>}
*/
export async function runBatchOverDocuments({ documents, runOne, delayMs = 0, onProgress = null } = {}) {
const list = Array.isArray(documents) ? documents.filter(Boolean) : [];
const results = [];
for (let index = 0; index < list.length; index += 1) {
const doc = list[index];
try {
const output = await runOne(doc, index);
results.push({ id: doc.id, name: doc.name, ok: true, output: output === undefined ? null : output });
} catch (error) {
results.push({ id: doc.id, name: doc.name, ok: false, error: String((error && error.message) || error) });
}
if (typeof onProgress === 'function') {
try { onProgress(index + 1, list.length, doc); } catch { /* progress is best-effort */ }
}
if (delayMs > 0) await new Promise(resolve => setTimeout(resolve, delayMs));
}
return results;
}
export default { runBatchOverDocuments };
+256
View File
@@ -0,0 +1,256 @@
/**
* Layer/graphic blend modes.
*
* Canvas2D can only composite a subset of the Photoshop blend modes natively.
* This module exposes that native set plus the "extra" modes PhotoCraft offers
* (Dissolve, Linear Burn, Darker Color, Linear Dodge/Add, Lighter Color,
* Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide) and a
* single `blendDraw` entry point that both paths go through.
*
* The non-native modes follow the W3C compositing spec (separable + the two
* non-separable luminance modes) and operate on ImageData in the overlap of
* the source and the destination so rendering stays reasonably cheap.
*/
export const NATIVE_BLEND_MODES = new Set([
'source-over',
'multiply',
'screen',
'overlay',
'darken',
'lighten',
'color-dodge',
'color-burn',
'hard-light',
'soft-light',
'difference',
'exclusion',
'hue',
'saturation',
'color',
'luminosity',
]);
const NATIVE_ALIASES = {
'linear-dodge': 'lighter',
add: 'lighter',
};
export const EXTRA_BLEND_MODES = [
{ value: 'linear-dodge', label: 'Linear Dodge (Add)' },
{ value: 'linear-burn', label: 'Linear Burn' },
{ value: 'darker-color', label: 'Darker Color' },
{ value: 'lighter-color', label: 'Lighter Color' },
{ value: 'vivid-light', label: 'Vivid Light' },
{ value: 'linear-light', label: 'Linear Light' },
{ value: 'pin-light', label: 'Pin Light' },
{ value: 'hard-mix', label: 'Hard Mix' },
{ value: 'subtract', label: 'Subtract' },
{ value: 'divide', label: 'Divide' },
{ value: 'dissolve', label: 'Dissolve' },
];
const NON_SEPARABLE = new Set(['darker-color', 'lighter-color']);
export function isNativeBlendMode(mode) {
if (!mode) return true;
return NATIVE_BLEND_MODES.has(mode) || Object.prototype.hasOwnProperty.call(NATIVE_ALIASES, mode);
}
export function isKnownBlendMode(mode) {
if (!mode) return true;
return isNativeBlendMode(mode) || EXTRA_BLEND_MODES.some(entry => entry.value === mode);
}
function clamp01(value) {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
function lum(r, g, b) {
return 0.299 * r + 0.587 * g + 0.114 * b;
}
// Separable per-channel blend functions on normalized [0,1] values.
function blendChannel(mode, cb, cs) {
switch (mode) {
case 'normal':
return cs;
case 'multiply':
return cb * cs;
case 'screen':
return cb + cs - cb * cs;
case 'overlay':
return cb <= 0.5 ? 2 * cb * cs : 1 - 2 * (1 - cb) * (1 - cs);
case 'darken':
return Math.min(cb, cs);
case 'lighten':
return Math.max(cb, cs);
case 'color-dodge':
if (cb === 0) return 0;
if (cs === 1) return 1;
return Math.min(1, cb / (1 - cs));
case 'color-burn':
if (cb === 1) return 1;
if (cs === 0) return 0;
return 1 - Math.min(1, (1 - cb) / cs);
case 'hard-light':
return cs <= 0.5 ? 2 * cs * cb : 1 - 2 * (1 - cs) * (1 - cb);
case 'soft-light': {
if (cs <= 0.5) return cb - (1 - 2 * cs) * cb * (1 - cb);
const d = cb <= 0.25 ? ((16 * cb - 12) * cb + 4) * cb : Math.sqrt(cb);
return cb + (2 * cs - 1) * (d - cb);
}
case 'difference':
return Math.abs(cb - cs);
case 'exclusion':
return cb + cs - 2 * cb * cs;
case 'linear-burn':
return clamp01(cb + cs - 1);
case 'linear-dodge':
return clamp01(cb + cs);
case 'vivid-light':
return cs <= 0.5
? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs)))
: (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs))));
case 'linear-light':
return clamp01(cb + 2 * cs - 1);
case 'pin-light':
return cs <= 0.5 ? Math.min(cb, 2 * cs) : Math.max(cb, 2 * cs - 1);
case 'hard-mix': {
const vivid = cs <= 0.5
? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs)))
: (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs))));
return vivid < 0.5 ? 0 : 1;
}
case 'subtract':
return clamp01(cb - cs);
case 'divide':
return cs === 0 ? 1 : clamp01(cb / cs);
default:
return cs;
}
}
function dissolveRandom(i) {
let seed = (i * 2654435761) >>> 0;
seed ^= seed >>> 13;
seed = (seed * 1274126177) >>> 0;
return seed / 4294967296;
}
function ensureSourceData(source) {
const sw = source.width || source.naturalWidth || 0;
const sh = source.height || source.naturalHeight || 0;
if (!sw || !sh) return null;
const scratch = document.createElement('canvas');
scratch.width = sw;
scratch.height = sh;
const sctx = scratch.getContext('2d');
sctx.drawImage(source, 0, 0, sw, sh);
return { data: sctx.getImageData(0, 0, sw, sh).data, sw, sh };
}
function blendCustom(ctx, source, mode, alpha, dx, dy) {
if (alpha <= 0) return;
const src = ensureSourceData(source);
if (!src) return;
const tw = ctx.canvas.width;
const th = ctx.canvas.height;
const sw = src.sw;
const sh = src.sh;
const sx0 = Math.max(0, -dx);
const sy0 = Math.max(0, -dy);
const dx0 = Math.max(0, dx);
const dy0 = Math.max(0, dy);
const w = Math.min(sw - sx0, tw - dx0);
const h = Math.min(sh - sy0, th - dy0);
if (w <= 0 || h <= 0) return;
const dst = ctx.getImageData(dx0, dy0, w, h);
const dd = dst.data;
const sd = src.data;
const nonSeparable = NON_SEPARABLE.has(mode);
const dissolve = mode === 'dissolve';
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const si = ((y + sy0) * sw + (x + sx0)) * 4;
const di = (y * w + x) * 4;
const sa = (sd[si + 3] / 255) * alpha;
const ba = dd[di + 3] / 255;
if (dissolve) {
const seed = (y + dy0) * 73856093 ^ (x + dx0) * 19349663;
if (dissolveRandom(seed) >= sa) continue;
dd[di] = sd[si];
dd[di + 1] = sd[si + 1];
dd[di + 2] = sd[si + 2];
dd[di + 3] = 255;
continue;
}
if (sa === 0) continue;
const sr = sd[si] / 255;
const sg = sd[si + 1] / 255;
const sb = sd[si + 2] / 255;
const br = dd[di] / 255;
const bg = dd[di + 1] / 255;
const bb = dd[di + 2] / 255;
let mergedR;
let mergedG;
let mergedB;
if (nonSeparable) {
const backdropLum = lum(br, bg, bb);
const sourceLum = lum(sr, sg, sb);
const useSource = mode === 'darker-color' ? sourceLum < backdropLum : sourceLum > backdropLum;
mergedR = useSource ? sr : br;
mergedG = useSource ? sg : bg;
mergedB = useSource ? sb : bb;
} else {
mergedR = blendChannel(mode, br, sr);
mergedG = blendChannel(mode, bg, sg);
mergedB = blendChannel(mode, bb, sb);
}
const srEff = (1 - ba) * sr + ba * mergedR;
const sgEff = (1 - ba) * sg + ba * mergedG;
const sbEff = (1 - ba) * sb + ba * mergedB;
const ao = sa + ba * (1 - sa);
if (ao <= 0) continue;
const cr = (sa * srEff + ba * br * (1 - sa)) / ao;
const cg = (sa * sgEff + ba * bg * (1 - sa)) / ao;
const cbOut = (sa * sbEff + ba * bb * (1 - sa)) / ao;
dd[di] = Math.round(clamp01(cr) * 255);
dd[di + 1] = Math.round(clamp01(cg) * 255);
dd[di + 2] = Math.round(clamp01(cbOut) * 255);
dd[di + 3] = Math.round(ao * 255);
}
}
ctx.putImageData(dst, dx0, dy0);
}
/**
* Composite `source` onto a 2D context (or canvas) using `mode` at `alpha`.
* Native modes use globalCompositeOperation; the rest fall back to pixel math.
*/
export function blendDraw(target, source, mode, alpha = 1, dx = 0, dy = 0) {
if (!target || !source) return;
const ctx = typeof target.getContext === 'function' ? target.getContext('2d') : target;
const a = Number.isFinite(alpha) ? clamp01(alpha) : 1;
const normalized = mode || 'source-over';
if (isNativeBlendMode(normalized)) {
const prevAlpha = ctx.globalAlpha;
const prevOp = ctx.globalCompositeOperation;
ctx.globalAlpha = a;
ctx.globalCompositeOperation = NATIVE_ALIASES[normalized] || normalized;
ctx.drawImage(source, dx, dy);
ctx.globalAlpha = prevAlpha;
ctx.globalCompositeOperation = prevOp;
return;
}
blendCustom(ctx, source, normalized, a, dx, dy);
}
+30 -20
View File
@@ -24,6 +24,32 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit
emit(emitted);
};
// Emit evenly spaced dabs from the last emitted sample toward `target`,
// carrying the sub-spacing remainder forward. Shared by drag updates and
// the final pointer-up so a stroke ends on the same spacing grid instead
// of stamping one detached dab (which reads as a dot where drawing stopped).
const emitSpaced = (target) => {
if (!emitted) return;
const start = { ...emitted };
const dx = target.x - start.x;
const dy = target.y - start.y;
const distance = Math.hypot(dx, dy);
if (!distance) return;
const diameter = Math.max(1, Number(getDiameter()) || 1);
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
let travelled = spacing - remainder;
while (travelled <= distance) {
const t = travelled / distance;
output({
x: start.x + dx * t,
y: start.y + dy * t,
pressure: start.pressure + (target.pressure - start.pressure) * t,
});
travelled += spacing;
}
remainder = Math.max(0, distance - (travelled - spacing));
};
const begin = (sample) => {
emitted = null;
filtered = { ...sample, pressure: normalisePressure(sample.pressure) };
@@ -41,30 +67,14 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit
y: filtered.y + (raw.y - filtered.y) * response,
pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response,
};
const start = { ...emitted };
const dx = filtered.x - start.x;
const dy = filtered.y - start.y;
const distance = Math.hypot(dx, dy);
if (!distance) return;
const diameter = Math.max(1, Number(getDiameter()) || 1);
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
let travelled = spacing - remainder;
while (travelled <= distance) {
const t = travelled / distance;
output({
x: start.x + dx * t,
y: start.y + dy * t,
pressure: start.pressure + (filtered.pressure - start.pressure) * t,
});
travelled += spacing;
}
remainder = Math.max(0, distance - (travelled - spacing));
emitSpaced(filtered);
};
const end = (sample) => {
// Reach the true pointer-up point along the spacing grid rather than with
// a single far dab, so the stroke does not terminate in a detached dot.
if (sample && emitted) {
const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) };
if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample);
emitSpaced({ ...sample, pressure: normalisePressure(sample.pressure) });
}
emitted = null;
filtered = null;
+3
View File
@@ -4,6 +4,7 @@ const BUILT_INS = [
{ id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' },
{ id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' },
{ id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' },
{ id: 'pixel-1px', name: 'Pixel', size: 1, opacity: 100, flow: 100, softness: 0, spacing: 1, smoothing: 0, blendMode: 'source-over', pixel: true },
];
export function loadBrushPresets(storage = globalThis.localStorage) {
@@ -31,6 +32,7 @@ export function captureBrushPreset(state, name) {
spacing: state.brushSpacing,
smoothing: state.brushSmoothing,
blendMode: state.brushBlendMode,
pixel: !!state.brushPixel,
};
}
@@ -43,4 +45,5 @@ export function applyBrushPreset(state, preset) {
state.brushSpacing = preset.spacing;
state.brushSmoothing = preset.smoothing;
state.brushBlendMode = preset.blendMode || 'source-over';
state.brushPixel = !!preset.pixel;
}
+80
View File
@@ -0,0 +1,80 @@
/** Command palette (Ctrl/Cmd+K): fuzzy list of tools, panels and actions. */
export function createCommandPalette({ commands = [], onRun = null } = {}) {
const overlay = document.createElement('div');
overlay.className = 'ge-command-palette';
overlay.hidden = true;
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Command palette');
overlay.innerHTML = `
<div class="ge-command-panel">
<input class="ge-command-input" type="text" placeholder="Type a command..." aria-label="Command" autocomplete="off" />
<div class="ge-command-list" role="listbox"></div>
</div>`;
document.body.appendChild(overlay);
const input = overlay.querySelector('.ge-command-input');
const list = overlay.querySelector('.ge-command-list');
let filtered = [];
let activeIndex = 0;
function render() {
const query = input.value.trim().toLowerCase();
filtered = commands.filter(cmd => !query || String(cmd.label).toLowerCase().includes(query));
list.innerHTML = '';
if (!filtered.length) {
const empty = document.createElement('p');
empty.className = 'ge-command-empty';
empty.textContent = 'No matching commands';
list.appendChild(empty);
return;
}
if (activeIndex >= filtered.length) activeIndex = 0;
filtered.forEach((cmd, index) => {
const row = document.createElement('button');
row.type = 'button';
row.className = `ge-command-item${index === activeIndex ? ' active' : ''}`;
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', index === activeIndex ? 'true' : 'false');
row.textContent = cmd.label;
row.addEventListener('click', () => run(cmd));
list.appendChild(row);
});
}
function run(cmd) {
close();
if (typeof cmd.run === 'function') cmd.run();
else if (typeof onRun === 'function') onRun(cmd);
}
function open() {
overlay.hidden = false;
input.value = '';
activeIndex = 0;
render();
input.focus();
}
function close() {
overlay.hidden = true;
}
input.addEventListener('input', () => { activeIndex = 0; render(); });
input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
else if (event.key === 'ArrowDown') { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); }
else if (event.key === 'ArrowUp') { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); }
else if (event.key === 'Enter') { event.preventDefault(); if (filtered[activeIndex]) run(filtered[activeIndex]); }
});
overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); });
return {
element: overlay,
open,
close,
toggle: () => (overlay.hidden ? open() : close()),
isOpen: () => !overlay.hidden,
};
}

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