Compare commits

...

749 Commits

Author SHA1 Message Date
CodingOnStar
cd6b4a4897 fix(web): remove unused access token translations blocking dev build 2026-09-29 16:54:37 +08:00
CodingOnStar
bcfb0a32d2 fix(web): hide installed plugins from workflow marketplace selectors
(cherry picked from commit f0f469b5e8)
2026-09-29 16:47:20 +08:00
Stephen Zhou
561c935415 fix(api): preserve resource token access across OpenAPI routes 2026-09-21 09:39:42 +08:00
Stephen Zhou
e003cc2918 Merge branch 'main' into deploy/dev 2026-09-21 09:34:16 +08:00
fatelei
f99d2b9434 fix: fix db migration 2026-09-20 13:29:38 +08:00
fatelei
0cae6dd813 Merge branch 'access_token' into deploy/dev 2026-09-20 11:54:53 +08:00
wangxiaolei
4ba0e68e56 feat: skill detail page draft content support export (#42502) 2026-09-18 16:31:46 +08:00
wangxiaolei
c0c658eef4 chore: remove skill export dialog (#42491) 2026-09-18 15:12:18 +08:00
zxhlyh
0bfeb23852 Merge branch 'codex/fix-skills-frontend-issues' into deploy/dev 2026-09-18 14:20:08 +08:00
zxhlyh
0c4074fc77 fix(skills): skip unavailable default builder models 2026-09-18 14:15:12 +08:00
zxhlyh
920410fda5 Merge branch 'codex/fix-skills-frontend-issues' into deploy/dev 2026-09-18 11:28:08 +08:00
zxhlyh
968694045d fix(skills): open duplicated skill for inline rename 2026-09-18 11:23:48 +08:00
zxhlyh
6d8d36ce06 fix(skills): reflect selected tags immediately 2026-09-18 11:00:02 +08:00
zxhlyh
aba44b75a6 fix(skills): show requirements before importing packages 2026-09-18 10:29:10 +08:00
zxhlyh
e01112cf6a fix(skills): align referenced TXT file icons 2026-09-18 10:12:17 +08:00
fatelei
dc42623bc0 Merge main into deploy/dev
Resolve Agent RBAC and marketplace conflicts against the current main interfaces. Preserve deploy/dev collection preview limits and join the migration heads.
2026-09-17 19:24:00 +08:00
fatelei
690d30fbbc feat: skill support duplicate and export use the latest draft 2026-09-17 17:16:26 +08:00
CodingOnStar
f6be752b5b fix: port marketplace image copy fixes to deploy/dev
Cherry-picked changes from 485c6b30dc, 2c419048a2, and afebe629f6. The image preview close fix is already present in 625c595409.
2026-09-17 16:44:02 +08:00
CodingOnStar
625c595409 fix(marketplace): hide detail close button during image preview
(cherry picked from commit e31856d7dc)

Preserve the deploy/dev gallery markup and adapt the regression selectors.
2026-09-17 15:47:02 +08:00
fatelei
0c72432ebc feat: cli support access token 2026-09-14 17:51:19 +08:00
fatelei
b0ae7744a1 feat: access token feature 2026-09-14 13:31:14 +08:00
CodingOnStar
95674ae71e fix(web): sync embedded template dialog with /templates/{publisher}/{id}
Open the marketplace template dialog from a Dify catalog URL, keep the
outer path in sync while it is open, and restore /templates on close.
2026-09-10 17:53:00 +08:00
CodingOnStar
7e27f10ea7 fix(web): allow clipboard-write on marketplace detail iframe
The embedded marketplace detail dialog is cross-origin. Without this
permission, navigator.clipboard.writeText is denied and Copy link fails.
2026-09-10 17:53:00 +08:00
CodingOnStar
0bd09d5b7a fix(web): use delivered cover_image for blog banners
Stop painting every blog slide with the bundled MCP Servers art. Keep
cover_image through banner normalization and render that artwork instead.
2026-09-10 16:37:25 +08:00
CodingOnStar
ba7df082bc feat(marketplace): port stable template UUID links to dev
Port 7f7e909826 while preserving deploy/dev Banner fallbacks and embedded navigation guards. Align the existing Banner call and tests with its unchanged analytics payload contract so the dev static gate passes.
2026-09-10 16:06:53 +08:00
CodingOnStar
0fb011126e fix(web): never navigate embedded recommend plugin or template cards
Dify console marketplace should open plugin and template dialogs for
catalog cards and recommend banners. Resolve plugin identity from
/plugins/org/name links when item_id is not org/name. The standalone
marketplace still navigates to the item page.
2026-09-09 16:48:27 +08:00
CodingOnStar
06d45f7c09 fix(web): open embedded recommend banner templates in the detail dialog
In-app marketplace recommend template cards now reuse the template
detail dialog, matching plugins. The standalone marketplace still
navigates to the template page.
2026-09-09 16:25:58 +08:00
CodingOnStar
0047772f15 Merge remote-tracking branch 'origin/feat/creator-profile-home' into deploy/dev 2026-09-03 14:19:08 +08:00
CodingOnStar
0584b26883 fix(web): satisfy vp check for marketplace catalog prefetch
PR style CI failed oxfmt on the catalog preview files. Split prefetch
out of the HydrateQueryClient module so the component file only exports
a component, and stop asserting extra list fields as Plugin.
2026-09-03 14:18:23 +08:00
CodingOnStar
b10d9aedd8 Merge remote-tracking branch 'origin/feat/creator-profile-home' into deploy/dev 2026-09-03 13:46:55 +08:00
CodingOnStar
ebccd86338 fix(web): overlap homepage banners with catalog prefetch
Banners and collection prefetch no longer run back-to-back 2.5s waits.
The home variant starts both under one server budget and reuses the
dehydrated state so a slow banner cannot add a second document stall.
2026-09-03 11:56:21 +08:00
CodingOnStar
aa40a404d9 fix(web): align embedded template collection previews with warmup
Embedded /templates still posted limit 24 and kept readme in the RSC
payload, so it missed the Redis bucket the scheduler writes at limit 20.
2026-09-03 11:48:59 +08:00
CodingOnStar
0b847ede3e fix(web): preview marketplace collection plugins at limit 20
Homepage carousels only show ~8 cards per page but posted an unbounded
collection body, so RSC hydrated full plugin rows. Match the template
preview contract so warmup can write the Redis key the catalog reads.
2026-09-03 11:45:45 +08:00
CodingOnStar
08b3585999 Merge remote-tracking branch 'origin/feat/creator-profile-home' into deploy/dev 2026-09-02 17:39:19 +08:00
CodingOnStar
463dc59d13 fix(web): navigate marketplace search results without App Router
Standalone suggestion clicks assign window.location instead of useRouter,
so marketplace tests can render the shared autocomplete without a Next
app-router tree.
2026-09-02 17:38:53 +08:00
CodingOnStar
f9106e5ccd Merge remote-tracking branch 'origin/feat/creator-profile-home' into deploy/dev 2026-09-02 17:24:39 +08:00
CodingOnStar
0960ef4f0f fix(web): open marketplace search suggestions as details
Drop View more from the autocomplete popup, match the panel width to the
search field, and send a chosen plugin or template to its detail instead
of submitting /search/all.
2026-09-02 17:23:52 +08:00
CodingOnStar
eb8e3616d2 Merge remote-tracking branch 'origin/deploy/dev' into deploy/dev 2026-09-02 16:40:18 +08:00
CodingOnStar
6317b5c49f Merge branch 'feat/creator-profile-home' into deploy/dev 2026-09-02 16:39:05 +08:00
CodingOnStar
39a289ffa7 Merge branch 'feat/creator-profile-home' into deploy/dev
# Conflicts:
#	api/controllers/console/agent/composer.py
#	api/controllers/console/agent/roster.py
2026-09-02 16:39:05 +08:00
CodingOnStar
630a3bd5d2 fix(web): keep standalone marketplace files in knip production
Mark the standalone host entries with `!` so knip --production traces
Marketplace, HydrateQueryClient, and MarketplaceLiveSearch.
2026-09-02 16:38:58 +08:00
CodingOnStar
f05875d390 fix(web): drop unused marketplace knip exports
Keep CREATOR_PAGE_SIZE, publisherPageHasMore, and MarketplaceSearchScope
file-local; they are only used in the same modules.
2026-09-02 16:29:19 +08:00
CodingOnStar
00ec45088f fix(dify-ui): wait for Form Dialog initial focus
The Name field is focused after the open animation, so asserting
immediately left the play function racing the trigger button.
2026-09-02 16:29:01 +08:00
CodingOnStar
7b0068fc27 fix(web): submit marketplace home search on Enter
Enter without a keyboard-highlighted suggestion submits the typed query,
matching View more, instead of opening a hovered result.
2026-09-02 16:15:33 +08:00
CodingOnStar
ea6c67b231 fix(web): paginate creator inventory with load more
SSR only the first publisher page instead of five sequential fetches,
and let the profile load remaining plugins and templates on demand.
Harden social_links and deps_plugins so dirty API payloads cannot crash
the creator page.
2026-09-02 15:22:17 +08:00
CodingOnStar
b86cc38f97 Merge remote-tracking branch 'origin/deploy/dev' into deploy/dev 2026-09-02 15:03:07 +08:00
CodingOnStar
d2b8b14ea8 Merge branch 'feat/creator-profile-home' into deploy/dev
Keep deploy/dev agent RBAC decorators where they conflicted with main.
2026-09-02 14:57:19 +08:00
CodingOnStar
17135522af fix(web): address marketplace review comments
Gate iframe install on plugin.install, keep the 15s deadline off package
downloads, abort cancelled collection fetches, bound /templates prefetch,
and stop caching partial template-collection failures. Revert unrelated
Main Nav order and icon styling, remove the unused performance E2E
bundle, and expose a standalone Marketplace entry instead of Knip path
aliases.
2026-09-02 14:25:14 +08:00
CodingOnStar
c04ca2ff36 Merge remote-tracking branch 'origin/main' into feat/creator-profile-home
# Conflicts:
#	e2e/package.json
2026-09-02 10:42:27 +08:00
fatelei
e85339e06e fix: fix conflict 2026-09-02 09:14:24 +08:00
fatelei
5fe4ceb651 fix: fix conflict 2026-09-02 09:09:32 +08:00
fatelei
d659610049 feat: add legacy app.acl.access_point_view and app.acl.access_point_manage 2026-09-02 09:01:40 +08:00
twwu
db012bb659 fix(i18n): add access point permission translations 2026-09-01 22:11:31 +08:00
CodingOnStar
00bff6a281 fix(web): ignore AbortError when canceling banner autoplay 2026-09-01 21:08:58 +08:00
CodingOnStar
d1403139a9 Merge branch 'feat/creator-profile-home' into deploy/dev 2026-09-01 19:46:17 +08:00
CodingOnStar
341a4d0935 fix(web): open in-app marketplace details from the whole card (ECO-483)
Plugin and template cards now open the detail dialog from the card surface
and keyboard, while Install stays its own action.
2026-09-01 19:32:39 +08:00
CodingOnStar
c346306cf8 fix(web): reuse standalone search popup in the embedded marketplace (ECO-482)
In-app marketplace search now uses the same autocomplete panel as the public
site so typing a query can pick plugins or templates without leaving the page.
2026-09-01 19:32:16 +08:00
CodingOnStar
8c3a046bba fix(web): add Guide tooltip to in-app marketplace header icon
The icon-only Guide control used by the Dify-embedded Marketplace header
now shows a "Guide" tooltip on hover and keyboard focus, with a matching
accessible name. Opening the docs dropdown is unchanged.
2026-09-01 19:31:53 +08:00
CodingOnStar
d8a376edb3 fix(web): remove the in-app marketplace header account entry (ECO-481)
The embedded marketplace already sits inside Dify chrome, so the extra avatar
in the marketplace header is redundant. Creator Center and docs stay.
2026-09-01 19:31:19 +08:00
CodingOnStar
d77d9a51cf fix(web): install marketplace plugins in place without a confirm dialog (ECO-477)
Clicking Install in the embedded plugin detail iframe starts the install
immediately and posts status back to the iframe, instead of opening a second
confirmation modal.
2026-09-01 19:30:57 +08:00
CodingOnStar
ae77dd29da fix(api): restore deploy/dev alembic revision c8e7f6a5b4d3
The deploy/dev database is stamped at empty merge revision
c8e7f6a5b4d3, which disappeared after deploy/dev was rewritten.
Reintroduce that revision on top of remove_agent_drive and merge
it with the current head so flask db upgrade can locate the DB
and apply the migrations that landed after 20 Aug.
2026-09-01 17:51:06 +08:00
CodingOnStar
ff260f9be3 Merge branch 'feat/creator-profile-home' into deploy/dev 2026-09-01 17:31:18 +08:00
CodingOnStar
36c0d8205a fix(web): prune unused oxlint barrel suppression
Removing the unused Trigger icon export left a no-barrel-files
suppression count of 3 on the plugin icon barrel. TS Common fails
when suppressions no longer match.
2026-09-01 10:04:49 +08:00
CodingOnStar
98ade77312 fix(web): drop unused Trigger plugin icon
Homepage catalog switched to the iconify trigger class, so knip failed
Style Check on the leftover generated SVG export.
2026-09-01 09:56:06 +08:00
CodingOnStar
93fd770780 chore(web): shorten marketplace timeout and Flight comments
Keep the constraint, drop incident-history prose that will go stale now
that tests encode the async-page rule and the 15s deadline.
2026-08-31 22:59:50 +08:00
CodingOnStar
642760f95d fix(web): reject unsafe marketplace banner hrefs
CMS/partner banner links only required a non-empty string, so
javascript: and other schemes could render on the console homepage.
Allow http(s) and same-origin relative paths only.
2026-08-31 22:58:50 +08:00
CodingOnStar
a6e14ee3ac fix(web): load remaining creator creations beyond the first page
Publisher plugins and templates were capped at page 1 of 40, so larger
catalogs were silently truncated. Follow the API total through a small
page budget.
2026-08-31 22:57:28 +08:00
CodingOnStar
ee60f5dd29 fix(web): surface creator profile API errors instead of 404
A timed-out or 500 publisher request was mapped to notFound(). Only a
successful empty creator response should 404; transport failures should
reach the route error boundary.
2026-08-31 22:56:14 +08:00
CodingOnStar
a2bf4c397b fix(web): paginate template search by locale language
Unfiltered search fetched mixed-language pages then filtered in the
client, so totals and pageCount were wrong. Send the UI locale to the
API and keep client filtering only for collection previews.
2026-08-31 22:54:59 +08:00
CodingOnStar
3780a9fe84 fix(web): dual-write marketplace banner impressions
Clicks already go to Amplitude and __marketplaceTracking__. Impressions
only hit Amplitude, so marketplace.dify.ai never recorded views.
2026-08-31 22:53:31 +08:00
CodingOnStar
42f7088d9d fix(web): keep templates route sync under the client shell
Async /templates pages under MainNavLayout Flight-double-resolve the
same way /marketplace did. Load data in an async child instead.
2026-08-31 22:52:06 +08:00
CodingOnStar
c9bc4018a1 fix(web): drop marketplace PWA tags from console layout
Console cloud.dify.ai was advertising missing manifest/icons and
swallowing ResizeObserver errors on every route. Keep favicon in
generateMetadata; PWA chrome belongs to marketplace.dify.ai.
2026-08-31 22:50:22 +08:00
CodingOnStar
df62ed7298 chore(web): restore unrelated shared components to main
Drop leftover Explore carousel overlay, unused CopyIcon className,
dotenv markdown alias, and the unused Trigger plugin icon so this
marketplace PR no longer carries those shared-component diffs.
2026-08-31 22:21:17 +08:00
fatelei
d5278f836f Merge branch 'agent_acl' into deploy/dev 2026-08-31 22:06:58 +08:00
fatelei
ce8ce7626e feat: agent backend api add rbac 2026-08-31 22:05:53 +08:00
CodingOnStar
5dec7c76d4 fix(web): finish banner loop reset without rAF
Settle the wrapping carousel from resetting to idle with rAF plus a
short timeout fallback so loop clones unmount even when animation frames
are delayed.
2026-08-31 20:09:01 +08:00
CodingOnStar
6864ba33cd fix(web): keep marketplace knip production exports live
Use shouldSearchMarketplacePlugins and HERO_GRID_PITCH_PX from production
catalog code, keep getStandaloneCreationHref exported via the marketplace
model entry, drop the test-only language helper, and remove an unused type import.
2026-08-31 20:08:22 +08:00
CodingOnStar
c48f932985 fix(web): drop unused marketplace knip exports
Un-export file-private creator-profile and banner-viewability symbols.
Restore model-provider hooks tests from origin/main, and mark the
standalone marketplace server helpers as knip entries so production
file checks stay green.
2026-08-31 19:54:35 +08:00
CodingOnStar
ce65f9ae4c merge: integrate remote skill-api restore into local lint fixes 2026-08-31 19:33:28 +08:00
CodingOnStar
8765886d6c fix(web): satisfy marketplace lint and restore skill APIs from main
Restore the current skill/agent stack from origin/main after the merge, then fix marketplace lint, formatting, and type mismatches so Style Check can pass.
2026-08-31 19:32:32 +08:00
CodingOnStar
f3c0aee073 merge: bring origin/main into feat/creator-profile-home
Keep the marketplace frontend branch current so leftover api/ diffs
against the old merge-base no longer appear in the PR.
2026-08-31 19:21:17 +08:00
CodingOnStar
0eec43d086 chore(api): drop leftover skill backend from marketplace PR
Restore api/ and the generated console tag/workspace contracts to
origin/main so this branch no longer reverts Agent Skill work that
already landed on main.
2026-08-31 19:20:40 +08:00
CodingOnStar
65af1ca9ec merge: bring origin/main into feat/creator-profile-home 2026-08-31 19:10:20 +08:00
CodingOnStar
66ed8776a1 fix(web): clamp desktop blog tags and hide mobile read more
Keep the green blog label on one line with ellipsis on desktop, let
the title wrap, and drop the mobile Read more control now that the
whole stacked slide is the link.
2026-08-31 18:51:48 +08:00
CodingOnStar
9301920fec fix(web): wrap mobile blog banner titles (ECO-476)
Let stacked blog titles wrap instead of single-line ellipsis, and grow
the 160px copy frame so the heading stays fully visible.
2026-08-31 18:07:45 +08:00
CodingOnStar
3030ff3e4e fix(web): stop stacking mobile search and tab padding
Tuck the catalog tabs under the sticky search padding so the banner
gap stays, but search and Plugins/Templates tabs no longer add two
gaps on top of each other.
2026-08-31 17:53:15 +08:00
CodingOnStar
1eccdf60ed fix(web): ellipsize mobile blog tag and title inside copy padding
Keep the green tag and title to one line with ellipsis so they stay
inside the 20px stacked copy inset instead of overflowing the right
edge.
2026-08-31 17:44:03 +08:00
CodingOnStar
9e4d667161 fix(web): keep mobile blog description two lines visible
Reserve 40px for the two-line description and ellipsize the title so
the stacked 160px copy frame no longer clips the second line.
2026-08-31 17:28:44 +08:00
CodingOnStar
1c5ec977ed fix(web): keep bottom padding under stuck mobile search (ECO-475)
Give the standalone mobile sticky search a 16px padding-bottom and
shift catalog sticky offset by the same amount so banners do not
sit flush against the field.
2026-08-31 17:20:40 +08:00
fatelei
1b2c11377f Merge branch 'agent_acl' into deploy/dev 2026-08-31 17:19:51 +08:00
fatelei
39093c0749 feat: agent support rbac 2026-08-31 17:18:15 +08:00
CodingOnStar
2b38ddd042 fix(web): clamp mobile blog banner subtitle and description
Ellipsize the subtitle at one line and the description at two lines
on the standalone marketplace mobile stacked blog frame.
2026-08-31 17:12:28 +08:00
CodingOnStar
a515fe0917 fix(web): pin desktop banner art so shrinking clips the right
Keep blog artwork at 400px and event/ad bitmaps at least 1200px on
PC so a narrower overflow-hidden frame trims the right, not the left.
2026-08-31 17:06:45 +08:00
CodingOnStar
006eb63bf2 fix(web): show mobile homepage banners at 800:721
Keep the 800x721 event/ad poster whole with contain instead of
cover-cropping it into the stacked 357px mobile frame.
2026-08-31 16:39:46 +08:00
CodingOnStar
19235d024e fix(web): crop homepage banners from the right
Keep event and ad artwork left-aligned with cover cropping so a narrower
frame trims the right edge on desktop and mobile.
2026-08-31 16:19:22 +08:00
CodingOnStar
c244b9d685 Merge origin/main into feat/creator-profile-home
Keep marketplace card-wrapper dialog and partner-only carousel autoplay.
Take main's skills permissions, skill card actions, and agent orchestrate
layout. Union oxlint suppressions from both sides.
2026-08-31 10:13:38 +08:00
CodingOnStar
027f90db27 fix(web): autoplay only partner collection carousels
Keep arrows and pagination on every collection carousel. Only the
Partners collection rotates on its own.
2026-08-31 10:00:21 +08:00
CodingOnStar
b83fb96827 fix(web): autoplay only partner collection carousels
Keep arrows and pagination on every collection carousel. Only the
Partners collection rotates on its own.
2026-08-31 09:57:29 +08:00
CodingOnStar
380b1de3ff fix(web): keep search-results search below the header
Search pages have no hero, so HomeSearch must not overlap the brand bar.
Allow a null catalog tab so neither Plugins nor Templates looks selected.
2026-08-30 10:24:24 +08:00
CodingOnStar
3c4a462ae4 fix(web): put template Partners CTA beside the title (ECO-472)
The plugin catalog already used the mobile partner header grid; the
Templates tab still rendered Become a Partner on the description row.
Share the same header styles so both catalogs match.
2026-08-30 00:44:09 +08:00
CodingOnStar
dfb8b2e376 fix(web): pin sticky marketplace search without jumping (ECO-473)
Keep the standalone mobile search sticky below the header instead of
dropping sticky, and stop Chromium from scrolling the in-flow search box
into view when the desktop header search is focused or typed into.
2026-08-30 00:22:57 +08:00
CodingOnStar
d8f30076b5 fix(web): keep partner CTA 12px from title (ECO-472) 2026-08-28 20:40:59 +08:00
CodingOnStar
d9d510e5ac fix(web): keep mobile marketplace search below header (ECO-473) 2026-08-28 20:13:00 +08:00
fatelei
ad20475144 Merge branch 'access_point' into deploy/dev 2026-08-28 19:26:51 +08:00
fatelei
f623e469a7 chore: update permission key name 2026-08-28 19:25:52 +08:00
fatelei
d5000f5218 Merge branch 'permission' into deploy/dev 2026-08-28 17:35:21 +08:00
fatelei
b3f1fd46eb Merge branch 'access_point' into deploy/dev 2026-08-28 17:19:34 +08:00
fatelei
17d73d0c55 feat: add access point permission 2026-08-28 17:15:59 +08:00
CodingOnStar
41cb3a8502 fix(web): keep marketplace search scroll stable 2026-08-28 14:48:34 +08:00
CodingOnStar
4a471894a9 fix(web): stabilize catalog tab header handoff (ECO-470) 2026-08-28 14:08:16 +08:00
fatelei
998ef75459 Merge branch 'skill_entry' into deploy/dev 2026-08-28 12:08:11 +08:00
fatelei
324b69bb3c feat: hide skill_entry when use has no skill.view permission 2026-08-28 12:01:09 +08:00
CodingOnStar
e2add239a8 fix: align marketplace search suggestions (ECO-455) 2026-08-28 11:35:30 +08:00
CodingOnStar
8dc32a121b fix(web): align mobile partner header (ECO-472) 2026-08-27 18:17:22 +08:00
CodingOnStar
87097815c0 fix(web): add mobile banner swipe (ECO-471) 2026-08-27 17:56:29 +08:00
CodingOnStar
2a7025e758 fix(web): smooth catalog tabs into header (ECO-470) 2026-08-27 17:07:19 +08:00
L1nSn0w
66a3c9620a fix(web): stop Marketplace search jitter and indefinite loading
Three independent defects made catalog search feel unstable.

Stale results flash on every search. `isSearchMode` was derived from the raw
nuqs `q` while the request body used the 500ms-debounced value, so keystroke #1
flipped into search mode with an empty query. That fired a full empty-query
search whose generic top-plugins list rendered for the debounce window before
the real results replaced it. Both now read the same debounced value.

Grid unmount on every query change. The infinite query had no
`placeholderData`, so `data` went undefined whenever the key changed and
ListWrapper's `!isLoading` gate tore the whole result grid out, collapsing the
container and jumping the scroll position. Measured against a live catalog, a
sort change blanked the grid for 152 of 360 sampled frames; with
`keepPreviousData` and an `isRefreshing` dim it is 0 of 461.

Requests that never settle. The Marketplace oRPC link called `globalThis.fetch`
with no deadline, so a stalled connection left the query pending forever with
no error state and no retry — the reported permanent spinner. Requests now
carry a 15s deadline composed with (never replacing) react-query's abort
signal. The server prefetch and the banner fetch get their own 2.5s budget:
awaiting them unbounded held the whole RSC response, pushing time-to-first-byte
to ~7s against a 3s-delayed API while the browser sat on the previous page.

Supporting changes:

- `getMarketplacePlugins` and `getMarketplaceCollectionsAndPlugins` no longer
  swallow every rejection into a successful empty page. That turned outages
  and aborted keystrokes into "no plugins found", suppressed retries, cached
  the emptiness for the full staleTime, and killed `getNextPageParam`
  permanently via `total: 0`. ListWrapper now renders a retry affordance.
- The collections fan-out is bounded to 4 concurrent requests instead of
  firing one per collection at once, and per-collection failures degrade to an
  empty carousel rather than a blank catalog.
- Scroll pagination measures once per frame instead of per scroll event. Each
  threshold hit calls `fetchNextPage`, which defaults to `cancelRefetch: true`,
  so an unthrottled burst aborted and restarted the in-flight page request —
  and the backend counts those aborts against its search circuit breaker.
- Cross-page results are deduped by `org/name`; Meilisearch resolves
  `install_count` ties by document order and the sync task rewrites those
  documents every minute, so pages can overlap and collide on React keys.
- Search queries no longer inherit `retry: 3`; ~7s of backoff reads as a hang.
2026-08-27 16:27:42 +08:00
CodingOnStar
cdcafa0827 fix(web): add subtle creator avatar shadow (ECO-469) 2026-08-27 16:24:19 +08:00
CodingOnStar
efb8db8db3 fix(web): keep creator profile background continuous (ECO-468) 2026-08-27 16:22:45 +08:00
CodingOnStar
74bfab20a6 fix(web): crop ad banners from the right (ECO-467) 2026-08-27 16:02:15 +08:00
CodingOnStar
259fe7116e fix(web): align mobile banner heights (ECO-466) 2026-08-27 15:58:54 +08:00
CodingOnStar
c31b852767 fix: remove persistent search result arrows (ECO-465) 2026-08-27 15:39:23 +08:00
CodingOnStar
e8954d609e fix: refine marketplace search result spacing (ECO-464) 2026-08-27 15:37:58 +08:00
CodingOnStar
abb8f5c239 fix: prevent marketplace search layout shift (ECO-462) 2026-08-27 15:33:42 +08:00
fatelei
5a7345c32d chore: add permission check back 2026-08-27 15:26:35 +08:00
CodingOnStar
54f4073f22 fix(web): make banner carousel loop seamlessly (ECO-458) 2026-08-27 15:02:31 +08:00
CodingOnStar
f3a08a78eb fix(web): preserve blog banner image corners (ECO-457) 2026-08-27 14:54:48 +08:00
CodingOnStar
2f7267962b fix(web): align marketplace catalog navigation (ECO-456) 2026-08-27 14:52:56 +08:00
CodingOnStar
96ba0cb1f8 fix(web): use divider between marketplace filters (ECO-454) 2026-08-27 14:28:04 +08:00
CodingOnStar
cb6d38d9f9 Revert "fix(web): keep creator profiles when only published work exists"
This reverts commit cd02aeb53c.
2026-08-26 18:05:13 +08:00
CodingOnStar
cd02aeb53c fix(web): keep creator profiles when only published work exists
Template author pages 404'd when the marketplace creators row was missing,
even if /templates/publisher/{handle} already returned public templates.
2026-08-26 18:02:54 +08:00
CodingOnStar
e7e502d1e7 merge: bring origin/main into feat/creator-profile-home 2026-08-26 15:28:59 +08:00
CodingOnStar
debda8ac5c fix(web): drop the extra rest-state border from marketplace search
Match the homepage Input Field: rest uses the fill only, with the gray
stroke only on hover and focus.
2026-08-25 19:34:00 +08:00
CodingOnStar
65d89e187f fix(web): snap marketplace hero icons to the grid and wash the title
Align the 41px hero grid to the same 50% origin as the six icons so
the 1px strokes sit flush with each tile. Match Figma Ellipse 5 for
the title/search wash, and size the hero to four grid rows so the
bottom icons are not clipped.
2026-08-25 18:59:26 +08:00
CodingOnStar
6476d0018c fix(web): fade marketplace home hero grid toward the edges
Match Figma's inset grid wash so the lines taper out instead of
meeting the viewport at full strength.
2026-08-25 15:22:34 +08:00
CodingOnStar
d942e963bb fix(web): restyle marketplace home hero with grid and six icons
Replace the cycling brand marks with the new Figma decorations: a
divider-subtle grid, six static category icons, and hide both on
mobile widths.
2026-08-25 14:47:36 +08:00
CodingOnStar
7181812aff fix(web): pick event/ad mobile banner images on phones
Phones use images.mobile when present, otherwise desktop. Tablet
media no longer matches mobile widths.
2026-08-25 13:55:11 +08:00
CodingOnStar
62c7b5c989 merge: bring origin/main into feat/creator-profile-home 2026-08-25 12:15:52 +08:00
CodingOnStar
0081fb4e2b fix(web): match standalone mobile recommend and event banners
Use the dedicated backdrop on independent-site recommend frames, and size event/ad mobile frames to the 800×721 slot with cover fill.
2026-08-25 12:11:52 +08:00
CodingOnStar
ebe06c7eee merge: bring origin/main into feat/creator-profile-home 2026-08-25 11:28:23 +08:00
CodingOnStar
87cb98087f fix(web): drop Forum from the help menu and label Discord
The sidebar help popup no longer links to the forum. The previous
Community entry now reads Discord and still opens the Discord invite.
2026-08-25 10:57:33 +08:00
CodingOnStar
6e5cd92eec fix(web): match standalone marketplace mobile banners and collections
Show recommend as icon-only on mobile, keep event/ad posters uncropped,
and hide collection carousel dots under 879px.
2026-08-24 19:02:46 +08:00
CodingOnStar
0da1c88de7 feat(web): add standalone marketplace homepage mobile layout
Stack homepage banners, search, and catalog to the 879px Figma mobile
frame on the independent marketplace site only. Event and ad slides use
the 357px mobile art frame; blog and recommend stay stacked 197+160.
2026-08-24 15:45:26 +08:00
CodingOnStar
c4615c3f2f fix(web): match marketplace banner play control to Figma 2026-08-24 14:40:11 +08:00
FFXN
c6b97a4936 Merge remote-tracking branch 'origin/main' 2026-08-24 14:00:11 +08:00
CodingOnStar
26763c33a1 fix(web): use current dialog close primitive 2026-08-24 12:50:40 +08:00
CodingOnStar
a9433195a1 feat(web): add Creator Center to the sidebar help menu 2026-08-24 12:39:10 +08:00
CodingOnStar
1503de3aa5 fix(web): hide bundles from marketplace plugin categories 2026-08-24 12:39:00 +08:00
CodingOnStar
e377dd0d91 merge: bring origin/main into feat/creator-profile-home
# Conflicts:
#	api/controllers/console/tag/tags.py
#	api/openapi/markdown/console-openapi.md
#	api/services/app_service.py
#	oxlint-suppressions.json
#	packages/contracts/generated/api/console/tags/types.gen.ts
#	packages/contracts/generated/api/console/tags/zod.gen.ts
#	packages/contracts/generated/api/console/workspaces/orpc.gen.ts
2026-08-24 12:15:37 +08:00
FFXN
21e2749356 fix(api): use external URL for plugin file previews 2026-08-24 11:35:15 +08:00
FFXN
731af391af Merge remote-tracking branch 'origin/main' 2026-08-24 11:27:30 +08:00
FFXN
368e52ea6b Merge remote-tracking branch 'origin/main' 2026-08-21 16:40:02 +08:00
FFXN
5c299c17c1 Merge remote-tracking branch 'origin/main' 2026-08-21 10:11:51 +08:00
FFXN
b20877fac2 Merge remote-tracking branch 'origin/main' 2026-08-20 18:11:25 +08:00
FFXN
ab9d0330ff Merge remote-tracking branch 'origin/fix/fix-snippet-export' 2026-08-20 16:34:56 +08:00
FFXN
ea4f31920b test(snippets): add unit tests for useExportSnippetMutation hook 2026-08-20 16:32:32 +08:00
autofix-ci[bot]
8d0603ec6f [autofix.ci] apply automated fixes 2026-08-20 08:14:53 +00:00
FFXN
3e00eb4b1e Merge remote-tracking branch 'origin/main'
# Conflicts:
#	api/controllers/console/snippets/snippet_workflow.py
#	api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py
#	packages/contracts/generated/api/console/snippets/orpc.gen.ts
2026-08-20 16:05:00 +08:00
CodingOnStar
b19ed8869c fix(web): restore marketplace header request/publish dropdown
The redesigned HomeHeader Guide action jumped straight to docs. Restore
the four-option book-icon dropdown used on production Marketplace.
2026-08-20 15:50:35 +08:00
CodingOnStar
e3b8e26192 merge: bring origin/main into feat/creator-profile-home
# Conflicts:
#	api/openapi/markdown/console-openapi.md
#	api/services/agent_config_service.py
#	api/tests/unit_tests/services/agent/test_skill_standardize_service.py
#	knip.config.ts
#	oxlint-suppressions.json
#	packages/contracts/generated/api/console/agent/zod.gen.ts
#	packages/contracts/generated/api/console/apps/zod.gen.ts
2026-08-20 15:18:59 +08:00
FFXN
4fcca7a66d fix(snippets): support exporting and deleting workflow versions
- export DSL from a selected published snippet workflow
- wire the snippet DSL export handler into version history
- add the missing DELETE endpoint for historical versions
- prevent deletion of draft and currently active versions
- regenerate API contracts and add regression tests
2026-08-20 15:11:34 +08:00
FFXN
6965728f11 Merge remote-tracking branch 'origin/main' 2026-08-20 11:42:18 +08:00
CodingOnStar
5088a4bb47 fix(web): align marketplace_banner_click site event payload
Map recommend banners to recommendation, emit theme/item/blog/ad/event
fields only for the matching click target, and keep banner language as
stored.
2026-08-20 11:38:25 +08:00
CodingOnStar
5a55909579 fix(web): use Creator Center default avatar on marketplace profiles
Match the missing-avatar fallback to Creator Center settings: brand-blue gradient and a white initial, instead of the gray letter tile.
2026-08-20 11:12:46 +08:00
CodingOnStar
4ef15cae6a fix(web): sort creator profile creations through publisher APIs
Pass sort_by and sort_order to the publisher plugin and template lists, and keep the mixed Creations grid ordered after both responses return.
2026-08-19 18:23:35 +08:00
CodingOnStar
803c3cd2df fix(web): restore creator profile contracts and i18n
The standalone and embedded creator pages call marketplaceClient.creatorDetail,
but those routes were dropped from the marketplace contract. Restore them, skip
avatar fetches when none was uploaded, and add creatorProfile strings to every
plugin locale.
2026-08-19 16:37:14 +08:00
CodingOnStar
62cb8a9c74 fix(web): resume trending autoplay after pointer pagination
Pointer clicks leave hover/focus on the dots, which kept rotation
paused until blur. Keyboard selection still keeps the focus pause.
2026-08-19 09:32:39 +08:00
CodingOnStar
1dfdc7c4f9 fix(web): stop streaming async marketplace children on cloud
HydrateQueryClient is an async Server Component. Under the client
console shell, Flight still double-resolves it and throws
reason.enqueueModel even when the route module itself is sync.
2026-08-19 09:21:20 +08:00
CodingOnStar
a5e1e94c0c fix(web): keep embedded marketplace routes synchronous
An async /marketplace page under the client console shell makes Flight
double-resolve the segment and throw reason.enqueueModel on
cloud.dify.dev. Banner fetching already lives on the client.
2026-08-19 09:10:09 +08:00
CodingOnStar
f1e0e463c1 Merge remote-tracking branch 'origin/main' into feat/creator-profile-home 2026-08-19 08:52:17 +08:00
CodingOnStar
3713581520 Merge remote-tracking branch 'origin/main' into feat/creator-profile-home
# Conflicts:
#	.editorconfig
#	oxlint-suppressions.json
#	web/app/components/base/markdown-blocks/code-block.tsx
#	web/app/layout.tsx
2026-08-18 21:30:57 +08:00
CodingOnStar
cde9aa5237 fix(web): keep marketplace route layout on the server
A client marketplace layout wrapping the async page makes Flight
double-resolve streamed children (`reason.enqueueModel`) on
cloud.dify.dev/marketplace.
2026-08-18 14:34:12 +08:00
CodingOnStar
803d5d3fe5 fix(web): keep marketplace autocomplete open during async search
Tie controlled open to the typing session so outside press can dismiss
during debounce, and keep Status/Empty mounted for the pending live region.
2026-08-18 11:39:23 +08:00
CodingOnStar
6ab0c21172 refactor(web): split marketplace autocomplete onto main popup anatomy
Use Autocomplete Portal/Positioner/Popup after the dify-ui split, restore
creator-profile selection, and move the language filter search to InputGroup.
2026-08-18 10:44:16 +08:00
CodingOnStar
4d8bd40058 merge: bring origin/main into feat/creator-profile-home
Keep marketplace workspace-skill runtime tests and take main's
autocomplete popup anatomy plus input-group composition.
2026-08-18 10:39:42 +08:00
CodingOnStar
5712cf91dc feat(web): emit marketplace site events from the shared catalog
Forward standalone marketplace Amplitude events from shared catalog
UI, including banner, card, filter, search, and creator/partner clicks.
2026-08-17 21:22:38 +08:00
CodingOnStar
ec9eb308ef feat(web): animate marketplace hero icons with grouped spring swaps
Cycle the four hero marks as a group every 4s, retracting toward the
title and expanding back out so later 4-set icon lists can drop in.
2026-08-17 17:15:56 +08:00
CodingOnStar
55dcaea347 merge: restore marketplace catalog tag and language filters
Combine homepage catalog filters with the later marketplace hero icon fixes.
2026-08-17 16:19:08 +08:00
CodingOnStar
d4c265551a fix(web): swap marketplace hero icons for themed Figma brand marks
Replace the homepage hero decorations with light/dark Dropbox, DuckDuckGo, GitHub, and Google assets, and hide them on narrow viewports.
2026-08-17 16:12:12 +08:00
CodingOnStar
f1ab5df40d fix(web): keep marketplace permission provider inside SSR
Wrapping HydrateQueryClient with the client install-permission provider
makes Flight double-resolve streamed children and throw enqueueModel.
2026-08-17 15:49:34 +08:00
姜涵煦
9b2368ca6b feat(web): restore templates language filter on the catalog bar 2026-08-17 15:27:32 +08:00
姜涵煦
fdc7338431 feat(web): put TagFilter to the left of marketplace plugin categories 2026-08-17 15:14:00 +08:00
CodingOnStar
2ee3927a10 fix(web): render the marketplace hero Gmail mark
The iconify CSS class masks to currentColor, so the multi-color Gmail
logo was an empty tile. Use the SVG asset the same way as DuckDuckGo.
2026-08-17 15:12:54 +08:00
姜涵煦
7440e27381 feat(web): restore marketplace languages query state 2026-08-17 15:08:35 +08:00
CodingOnStar
7ed77c05d0 feat(web): track marketplace banner impressions and clicks
Count a frame impression after the current slide stays 50% visible for 1s, and emit Amplitude click/item_click events that match the banner export schema.
2026-08-17 14:28:49 +08:00
CodingOnStar
fab9d78c48 fix(web): request templates banners on the templates page 2026-08-17 14:14:30 +08:00
CodingOnStar
24b977da27 Merge branch 'feat/marketplace-homepage-redesign' into feat/creator-profile-home 2026-08-17 13:01:32 +08:00
CodingOnStar
f5ac519089 Merge branch 'main' into feat/marketplace-homepage-redesign 2026-08-17 12:54:54 +08:00
CodingOnStar
90692c2d92 fix(web): show marketplace search results without a searching placeholder
Keep the suggestion popup closed until results settle so the standalone
site does not flash the missing gotoAnything.searching key. Render
plugin and template icons from the marketplace asset URLs.
2026-08-16 17:14:59 +08:00
CodingOnStar
94aa8adebf fix: keep marketplace search autocomplete as a knip entry 2026-08-16 16:59:23 +08:00
CodingOnStar
1b4461f1c1 Merge branch 'main' into feat/marketplace-homepage-redesign 2026-08-16 16:40:44 +08:00
CodingOnStar
0746dcc354 fix(web): keep Creator Center on the matching marketplace environment
Prefer the current marketplace origin so marketplace.dify.dev opens creators.dify.dev instead of inheriting a baked-in .ai URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 16:31:03 +08:00
CodingOnStar
ea6935060b fix(web): keep embedded marketplace search live without suggestions
The public marketplace owns the suggestion dropdown. Console plugin and
template search keep updating results as the query changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 16:28:01 +08:00
CodingOnStar
cc2aa8a5d9 Merge remote-tracking branch 'origin/feat/marketplace-homepage-redesign' into feat/marketplace-homepage-redesign 2026-08-16 15:24:57 +08:00
CodingOnStar
cf121dcc10 refactor(web): share the marketplace home shell and validate banners with zod
Compose the Plugins and Templates catalog homes from one HomeShell so the
page scaffold cannot drift, split the templates page into category
navigation, pagination and link helpers, and hand the collection list
pre-filtered locale data instead of filtering twice. Replace the
hand-rolled banner payload guards with a zod schema keeping the same
lenient semantics, share the partner-collection constants, label the
plugin type filter group as categories, and backfill the missing Lao
trending title.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 15:22:51 +08:00
autofix-ci[bot]
d6ca57ca14 [autofix.ci] apply automated fixes 2026-08-16 07:07:33 +00:00
CodingOnStar
0fc3791bb6 refactor(web): split marketplace home trending and share catalog constants
Break the trending banner into navigation and slide modules with shared
home constants, hoist the marketplace container id and partner-collection
constants for reuse across the catalog, and drop the i18n keys the
redesigned home no longer renders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 15:04:39 +08:00
CodingOnStar
66f63a074b fix(web): stop marketplace carousel autoplay from crashing on a single page
The autoplay plugin skips its own setup when a carousel has one page, so
the eager play() path dereferenced an undefined delay list and crashed
the standalone marketplace templates tab. Keep autoplay stopped for
single-page carousels and pause rotation permanently once focus enters,
dropping the play/pause toggle that resumed it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 15:03:10 +08:00
CodingOnStar
5f52b94aa0 fix(web): declare webp assets and drop an unused carousel export
CI type-checks without the Next-generated image declarations, so *.webp
needs an explicit module declaration like the other formats. Also keep
getCarouselItemsPerPage module-private since only the hook consumes it,
which unblocks the knip production check.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 14:01:08 +08:00
CodingOnStar
a4a57ec662 fix(web): address marketplace review follow-ups on assets, locale and a11y
- resample the 3MB trending background to a 97KB webp and serve a frozen
  recommend banner from the E2E stub so the benchmark covers the carousel
- show the on-page filtered count in template search and redirect
  out-of-range pages to the last available page
- localize the carousel controls across all 24 locales and replace the
  i18n type casts with typed selectors
- surface Marketplace API failures with a retry action instead of
  rendering them as empty results
- use iconify marks for the hero decorations, share the carousel
  page-size hook to avoid a hydration mismatch, move banner types into
  @dify/contracts, scope the Cmd+K shortcut to the standalone platform,
  point the embedded logo at /marketplace, pass resolvedTheme to the
  detail dialogs, and reveal the detail iframe after a loading timeout

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 13:48:34 +08:00
CodingOnStar
3087b45017 fix(web): render marketplace Creator Center from a client module
home-header.tsx is a server component on the standalone marketplace
homepage, where calling buttonVariants() from the client-marked
dify-ui button module crashes SSR with a 500. Extract the Creator
Center link into its own 'use client' file, mirroring home-guide.tsx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 11:35:18 +08:00
CodingOnStar
618f2b8e00 style: remove gray border lines from marketplace home header
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 19:46:59 +08:00
CodingOnStar
7eb37099d9 fix: restore marketplace entry modules consumed by dify-marketplace
The knip production check flagged these as unused, but the outer
dify-marketplace repository imports them via path aliases into its own
Next.js app, so deleting them broke that build. Restore them and
declare them as knip entries with the cross-repo consumer documented.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 16:06:57 +08:00
CodingOnStar
e07dd9e2f2 style: exempt generated suppression files from final-newline rule
autofix.ci regenerates oxlint-suppressions.json without a trailing
newline, which SuperLinter's editorconfig check then rejects, creating
an endless fix/revert loop between the two bots. Exempt the generated
suppression files instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 15:02:34 +08:00
CodingOnStar
71d0462287 fix: remove dead marketplace entry files and relax long-task budget
knip production flagged web/app/components/plugins/marketplace/index.tsx
and home/marketplace-live-search.tsx as unused after the homepage page
composed EmbeddedMarketplace directly; drop them with their specs. Raise
LONG_TASK_BUDGET_MS to 300ms since hydration peaks ~220ms on shared CI
runners under 4x CPU throttling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 14:53:19 +08:00
autofix-ci[bot]
83b53d7b5b [autofix.ci] apply automated fixes 2026-08-15 06:44:03 +00:00
CodingOnStar
e7ca2735bf fix: address marketplace review feedback on CI lane, templates and carousel a11y
- move the marketplace performance E2E lane out of main CI (opt-in workflow_dispatch)
- add server-rendered pagination for template search and validate page/view/sort params at the route boundary
- match template locale filtering by language prefix with an explicit English fallback
- cache the template collection fan-out (TTL + in-flight dedupe), lower per-collection limit and batch requests
- reset the forced search mode atom when the plugin catalog mounts
- clear stale search suggestions during the debounce window
- let the trending Play control override focus/hover pause reasons
- honor prefers-reduced-motion on the eagerly playing first-collection carousel
- give the Creator Center link an accessible, localized name
- replace the nested main landmark on the templates page with a div
- localize banner read-more actions and backfill marketplace.home keys across all 24 locales

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 14:40:03 +08:00
autofix-ci[bot]
05acab169b [autofix.ci] apply automated fixes 2026-08-15 06:01:08 +00:00
CodingOnStar
7b5bc3dc50 style: restore final newline in oxlint-suppressions.json
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 13:58:29 +08:00
autofix-ci[bot]
39b7b3ca51 [autofix.ci] apply automated fixes 2026-08-15 05:53:57 +00:00
CodingOnStar
d825dd75dc Merge remote-tracking branch 'origin/main' into feat/marketplace-homepage-redesign
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	oxlint-suppressions.json
#	web/app/account/oauth/authorize/__tests__/page.spec.tsx
#	web/app/account/oauth/authorize/page.tsx
#	web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx
2026-08-15 13:44:00 +08:00
CodingOnStar
f033532d7b fix(web): address marketplace review feedback and clean up login code
Resolve the review comments on the marketplace homepage: restore list SSR
hydration with an eager first collection, make the loading detail iframe
inert, drop stale search suggestions, add carousel pause controls and
off-screen a11y handling, widen the trending pause boundary, bind initial
banners to the server locale, and remove nested interactive links plus dead
CSS scan config. Refactor the marketplace silent-authorize hook to be
self-contained, extract shared OAuth URL helpers, and delete the unused
allowedAbsoluteOrigins option. Serve the performance benchmark from a frozen
local marketplace API stub and gate it with a dedicated Chromium CI lane.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 18:03:10 +08:00
CodingOnStar
cade438670 fix(web): unblock marketplace homepage CI
Stop exporting unused marketplace helpers and remove the leftover
Trigger plugin icon. Assert the card icon through its img src instead
of a background-image style the marketplace variant no longer uses.
2026-08-14 13:44:25 +08:00
CodingOnStar
14e26b3768 fix(web): make the blog banner fully clickable
Wrap the entire blog banner slide in the destination link so the
title, body, artwork, and Read more label all open the same URL.
2026-08-14 13:21:43 +08:00
CodingOnStar
cc06433dd3 fix(web): stop allowing Marketplace origin as a post-login redirect
Marketplace login returns to the same-origin authorize URL, so the
Marketplace prefix does not belong on the post-login allowlist.

Also add that origin to frame-src so the detail iframe is not blocked
when a CSP whitelist is enabled.
2026-08-14 12:38:03 +08:00
CodingOnStar
3785891102 fix(web): silent-authorize Marketplace by client id only
The flow=marketplace query was a hidden handshake with the Marketplace
authorize URL. Cloud now treats the configured Marketplace client as
first-party and skips consent without that marker.

Extract the silent-authorize effect so the generic OAuth page no longer
grows Marketplace-specific state.
2026-08-14 12:37:57 +08:00
CodingOnStar
f1d8d00ab7 fix(web): align marketplace hero decoration icons with Figma
Use the design-frame coordinates for Dropbox, DuckDuckGo, GitHub, and Gmail.
2026-08-14 10:53:31 +08:00
CodingOnStar
889bd18da4 Merge branch 'feat/marketplace-homepage-redesign' into feat/creator-profile-home 2026-08-14 10:34:47 +08:00
autofix-ci[bot]
8dcd7073de [autofix.ci] apply automated fixes 2026-08-14 02:34:31 +00:00
CodingOnStar
83805282fd Merge branch 'main' into feat/marketplace-homepage-redesign 2026-08-14 10:30:13 +08:00
CodingOnStar
d69ba7a8ee feat(web): add marketplace search decoration icons
Place Dropbox, DuckDuckGo, GitHub, and Gmail marks around the marketplace home search field.
2026-08-13 23:14:39 +08:00
CodingOnStar
685aaa7ef4 fix(web): harden marketplace oauth authorization 2026-08-13 14:53:20 +08:00
CodingOnStar
47d3663989 fix: remove marketplace detail dialog gradient 2026-08-13 14:52:43 +08:00
CodingOnStar
eac3d01f78 fix(web): match Creator Center dark text 2026-08-13 14:52:43 +08:00
CodingOnStar
17c1aaf944 fix(web): resolve marketplace lint failures 2026-08-13 14:52:43 +08:00
CodingOnStar
f457b40293 fix(web): keep marketplace tabs embedded 2026-08-13 14:52:43 +08:00
CodingOnStar
8fc21b7df1 fix(web): keep marketplace headers sticky 2026-08-13 14:52:43 +08:00
CodingOnStar
4f7e74cb30 fix(web): harden Creator Center link 2026-08-13 14:52:42 +08:00
CodingOnStar
8e26acd35b fix(web): restore creator center header link 2026-08-13 14:52:42 +08:00
CodingOnStar
a9861421de fix(web): stabilize marketplace search layout 2026-08-13 14:52:42 +08:00
CodingOnStar
8220f05b34 feat: add marketplace template detail dialog 2026-08-13 14:52:42 +08:00
CodingOnStar
fe05ace87b fix: remove duplicate marketplace clear control 2026-08-13 14:52:42 +08:00
CodingOnStar
c3f7c7c0c4 fix: restore marketplace search interactions 2026-08-13 14:52:42 +08:00
CodingOnStar
1bf2d191fe feat(web): add embedded marketplace templates 2026-08-13 14:52:42 +08:00
CodingOnStar
00861ac5eb perf(web): defer offscreen marketplace content 2026-08-13 14:52:42 +08:00
CodingOnStar
02f41e145b fix(web): preserve marketplace plugin caches 2026-08-13 14:52:08 +08:00
CodingOnStar
6f35232013 feat(web): support standalone marketplace plugin SSR 2026-08-13 14:52:08 +08:00
CodingOnStar
99ecbff011 feat: integrate Marketplace cloud auth 2026-08-13 14:52:08 +08:00
CodingOnStar
3d879197f3 chore(web): format marketplace after rebase 2026-08-13 14:52:08 +08:00
CodingOnStar
6c28ef44ce fix(web): open plugin installer from marketplace detail 2026-08-13 14:52:08 +08:00
CodingOnStar
0facec971f fix: repair marketplace entry auth flows 2026-08-13 14:52:08 +08:00
CodingOnStar
dbe9309ab8 fix: isolate standalone marketplace shared UI 2026-08-13 14:52:08 +08:00
CodingOnStar
3872eeb7f6 feat: support marketplace silent OAuth authorization 2026-08-13 14:52:08 +08:00
CodingOnStar
7910c10420 fix(web): pass marketplace plugin install state 2026-08-13 14:51:25 +08:00
CodingOnStar
c369cf211a fix(web): stabilize marketplace detail embeds 2026-08-13 14:51:25 +08:00
CodingOnStar
0bf3f5cc8d chore: remove detail dialog readme 2026-08-13 14:51:25 +08:00
CodingOnStar
3cc847dc36 feat: open marketplace details in dialog 2026-08-13 14:51:25 +08:00
CodingOnStar
4df28a09cf chore: include marketplace app styles 2026-08-13 14:50:52 +08:00
CodingOnStar
141c721a4a fix: add hybrid SSR for embedded marketplace 2026-08-13 14:50:52 +08:00
CodingOnStar
dcb4b4f6af fix: isolate embedded marketplace client 2026-08-13 14:50:52 +08:00
CodingOnStar
aa8f1fcd3a fix: simplify marketplace home tabs 2026-08-13 14:50:52 +08:00
CodingOnStar
33ef9a75f2 fix: localize marketplace homepage copy 2026-08-13 14:50:51 +08:00
CodingOnStar
effbe2ea32 feat: add themed marketplace logo 2026-08-13 14:50:51 +08:00
CodingOnStar
83883ad970 feat: share marketplace home shell with templates 2026-08-13 14:50:51 +08:00
CodingOnStar
5bd01c2060 fix: stabilize marketplace home sticky navigation 2026-08-13 14:50:51 +08:00
CodingOnStar
3fb85f76ad feat: complete marketplace trending carousel 2026-08-13 14:50:51 +08:00
CodingOnStar
09698d3cca refactor: preserve marketplace home server rendering 2026-08-13 14:50:50 +08:00
CodingOnStar
a222552d92 feat: align marketplace hero with redesign 2026-08-13 14:50:50 +08:00
CodingOnStar
33049c22f8 fix: restore marketplace category icons 2026-08-13 14:50:50 +08:00
CodingOnStar
923d92f9f5 feat: add sticky marketplace home navigation 2026-08-13 14:50:50 +08:00
CodingOnStar
1359e57e70 fix: restore marketplace home filter styles 2026-08-13 14:50:50 +08:00
CodingOnStar
f7ae7b6d01 fix: align marketplace home filters to left 2026-08-13 14:50:49 +08:00
CodingOnStar
744e5bb39b feat: add marketplace home catalog filters 2026-08-13 14:50:49 +08:00
CodingOnStar
7872c168b4 fix: align marketplace brand typography 2026-08-13 14:50:49 +08:00
CodingOnStar
b69df4d8b8 fix: align embedded marketplace header 2026-08-13 14:50:49 +08:00
CodingOnStar
0aa20ae2d7 fix: enable redesigned marketplace route 2026-08-13 14:50:48 +08:00
CodingOnStar
8bff4ad6a6 fix: align marketplace homepage with standalone design 2026-08-13 14:50:48 +08:00
CodingOnStar
270568f852 feat: redesign marketplace homepage 2026-08-13 14:50:48 +08:00
CodingOnStar
e91a8edc36 fix(web): resolve deploy build regressions 2026-08-12 14:24:41 +08:00
CodingOnStar
8102836ca8 Merge branch 'feat/creator-profile-home' into deploy/dev
# Conflicts:
#	oxlint-suppressions.json
#	packages/contracts/marketplace.ts
#	web/app/(commonLayout)/marketplace/page.tsx
#	web/app/components/main-nav/routes.ts
#	web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx
#	web/app/components/plugins/marketplace/detail-dialog/index.tsx
#	web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx
#	web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx
#	web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx
#	web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx
#	web/app/components/plugins/marketplace/home/home-header.tsx
#	web/app/components/plugins/marketplace/home/home-search.tsx
#	web/app/components/plugins/marketplace/home/home-trending.tsx
#	web/app/components/plugins/marketplace/home/index.tsx
#	web/app/components/plugins/marketplace/index.tsx
#	web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx
#	web/app/components/plugins/marketplace/list/list-wrapper.tsx
#	web/app/components/plugins/marketplace/view.tsx
2026-08-12 12:53:47 +08:00
zxhlyh
685f611f86 Merge branch 'skill' into deploy/dev 2026-08-11 18:29:46 +08:00
zxhlyh
01552e03d6 fix(skills): align markdown placeholder with caret 2026-08-11 18:26:28 +08:00
CodingOnStar
3094a00211 fix(web): prevent duplicate oauth authorization 2026-08-11 17:59:48 +08:00
fatelei
e1bacf098a Merge branch 'skill' into deploy/dev 2026-08-11 17:56:57 +08:00
fatelei
37510f544f fix: fix agent pull skill name wrong 2026-08-11 17:56:17 +08:00
fatelei
33e5b84074 Merge branch 'skill' into deploy/dev 2026-08-11 15:24:09 +08:00
fatelei
953b264c07 fix: fix skill name not refresh 2026-08-11 15:19:57 +08:00
zxhlyh
c440fa1beb fix(skills): reload latest draft after conflicts 2026-08-11 15:01:28 +08:00
fatelei
330288ba3d Merge branch 'skill' into deploy/dev 2026-08-11 14:30:20 +08:00
fatelei
d95049cec8 fix: fix permission 2026-08-11 14:30:04 +08:00
zxhlyh
615628e4f0 fix(skills): avoid duplicate publish errors 2026-08-11 13:43:28 +08:00
fatelei
7d2a377374 Merge branch 'skill' into deploy/dev 2026-08-11 12:15:33 +08:00
fatelei
080a09b0f5 chore: add miss file 2026-08-11 12:15:11 +08:00
fatelei
f3df36aec1 fix: fix scroll 2026-08-11 11:57:55 +08:00
fatelei
a87e396b02 feat: add permission ui check 2026-08-11 11:57:50 +08:00
fatelei
6e4893f37f fix: fix scroll 2026-08-11 11:57:09 +08:00
fatelei
c7ae155a07 feat: add permission ui check 2026-08-11 11:53:43 +08:00
fatelei
a5d8af27d0 Merge branch 'skill' into deploy/dev 2026-08-11 11:32:39 +08:00
fatelei
179968303a fix: fix agent read wrong skill name and description 2026-08-11 11:31:49 +08:00
zxhlyh
202f650b7c fix(skills): localize published status 2026-08-11 11:09:32 +08:00
zxhlyh
f23739272f fix(skills): align tag selector interactions 2026-08-11 11:06:39 +08:00
fatelei
0958b120e2 Merge branch 'skill' into deploy/dev 2026-08-11 10:55:54 +08:00
fatelei
e6bf7d333c fix: fix agent delete not update skill ref 2026-08-11 10:55:23 +08:00
zxhlyh
98ff51d570 fix(skills): preserve markdown editor position 2026-08-11 10:49:17 +08:00
fatelei
b1249dfc79 Merge branch 'skill' into deploy/dev 2026-08-11 07:25:15 +08:00
fatelei
eef27dfc8e fix: fix publish 2026-08-11 07:24:54 +08:00
fatelei
85dd61de3a fix: fix ci failed 2026-08-11 07:20:09 +08:00
fatelei
fee28ca253 fix: fix ci failed 2026-08-10 22:16:26 +08:00
fatelei
1cbea4a95f Merge branch 'skill' into deploy/dev 2026-08-10 21:23:25 +08:00
fatelei
b1f11384b5 feat: version conflict dialog 2026-08-10 21:20:28 +08:00
autofix-ci[bot]
890c062c01 [autofix.ci] apply automated fixes 2026-08-10 09:44:14 +00:00
fatelei
ed4dd78210 Merge branch 'skill' into deploy/dev 2026-08-10 17:40:56 +08:00
fatelei
8bafbf7238 fix: fix list tag and duplicate skill 2026-08-10 17:40:02 +08:00
zxhlyh
f6522e6a34 fix(skills): hide empty builder draft marker 2026-08-10 17:17:40 +08:00
zxhlyh
a5336da117 fix(agent): enable skill tag filtering 2026-08-10 17:05:48 +08:00
zxhlyh
a0d2d0302e fix(skills): render markdown file previews 2026-08-10 16:43:29 +08:00
zxhlyh
f5ea9bbc02 fix(skills): commit custom metadata on blur 2026-08-10 16:11:34 +08:00
zxhlyh
3d7c403580 fix(skills): discard stale uploads after restart 2026-08-10 15:28:07 +08:00
zxhlyh
c3554ad525 fix(skills): preserve attachments when retrying 2026-08-10 14:29:58 +08:00
fatelei
f805038d16 fix: fix conflict 2026-08-10 14:01:55 +08:00
fatelei
53542cf84e fix: fix draft skill time display 2026-08-10 13:47:13 +08:00
zxhlyh
6609ac0b1e fix(skills): guard deletion during reference refresh 2026-08-10 13:43:20 +08:00
CodingOnStar
7928826b0f feat(web): add marketplace creator profile 2026-08-10 10:43:12 +08:00
CodingOnStar
2a38ef22e5 feat: add marketplace template detail dialog 2026-08-10 10:43:11 +08:00
CodingOnStar
011863ee33 fix: remove duplicate marketplace clear control 2026-08-10 10:43:11 +08:00
CodingOnStar
ab3bb9dd6c fix: restore marketplace search interactions 2026-08-10 10:43:11 +08:00
CodingOnStar
b5082d2796 feat(web): add embedded marketplace templates 2026-08-10 10:43:11 +08:00
CodingOnStar
b388ae71d7 perf(web): defer offscreen marketplace content 2026-08-10 10:43:11 +08:00
CodingOnStar
dfa144da3f fix(web): preserve marketplace plugin caches 2026-08-10 10:43:11 +08:00
CodingOnStar
e12eaf055c feat(web): support standalone marketplace plugin SSR 2026-08-10 10:43:11 +08:00
CodingOnStar
cda6b22a0c feat: integrate Marketplace cloud auth 2026-08-10 10:43:11 +08:00
CodingOnStar
3a2a400dcb chore(web): format marketplace after rebase 2026-08-10 10:43:11 +08:00
CodingOnStar
bae833a355 fix(web): open plugin installer from marketplace detail 2026-08-10 10:43:11 +08:00
CodingOnStar
8fa6f74a17 fix: repair marketplace entry auth flows 2026-08-10 10:43:11 +08:00
CodingOnStar
e7019ad7e0 fix: isolate standalone marketplace shared UI 2026-08-10 10:43:11 +08:00
CodingOnStar
610d371112 feat: support marketplace silent OAuth authorization 2026-08-10 10:43:10 +08:00
CodingOnStar
d6ebd4ec2b fix(web): pass marketplace plugin install state 2026-08-10 10:43:10 +08:00
CodingOnStar
57e62bb226 fix(web): stabilize marketplace detail embeds 2026-08-10 10:43:10 +08:00
CodingOnStar
1b2c8b5737 chore: remove detail dialog readme 2026-08-10 10:43:10 +08:00
CodingOnStar
f72c498a8a feat: open marketplace details in dialog 2026-08-10 10:43:10 +08:00
CodingOnStar
3c5204c8c7 chore: include marketplace app styles 2026-08-10 10:43:10 +08:00
CodingOnStar
a8ffc6072d fix: add hybrid SSR for embedded marketplace 2026-08-10 10:43:10 +08:00
CodingOnStar
3161f70ac3 fix: isolate embedded marketplace client 2026-08-10 10:43:10 +08:00
CodingOnStar
36fcc3499d fix: simplify marketplace home tabs 2026-08-10 10:43:10 +08:00
CodingOnStar
1f40540ef0 fix: localize marketplace homepage copy 2026-08-10 10:43:09 +08:00
CodingOnStar
c52f4eb61e feat: add themed marketplace logo 2026-08-10 10:43:09 +08:00
CodingOnStar
bafb0a689f feat: share marketplace home shell with templates 2026-08-10 10:43:09 +08:00
CodingOnStar
8e670491a3 fix: stabilize marketplace home sticky navigation 2026-08-10 10:43:09 +08:00
CodingOnStar
ecf72a0860 feat: complete marketplace trending carousel 2026-08-10 10:43:09 +08:00
CodingOnStar
742d1b93b0 refactor: preserve marketplace home server rendering 2026-08-10 10:43:09 +08:00
CodingOnStar
9f7528b10f feat: align marketplace hero with redesign 2026-08-10 10:43:09 +08:00
CodingOnStar
505d3d9f03 fix: restore marketplace category icons 2026-08-10 10:43:09 +08:00
CodingOnStar
a214565031 feat: add sticky marketplace home navigation 2026-08-10 10:43:09 +08:00
CodingOnStar
fc8e796620 fix: restore marketplace home filter styles 2026-08-10 10:43:08 +08:00
CodingOnStar
433a553f88 fix: align marketplace home filters to left 2026-08-10 10:43:08 +08:00
CodingOnStar
3ed6d9f9af feat: add marketplace home catalog filters 2026-08-10 10:43:08 +08:00
CodingOnStar
f0917cc325 fix: align marketplace brand typography 2026-08-10 10:43:08 +08:00
CodingOnStar
56bac46fd6 fix: align embedded marketplace header 2026-08-10 10:43:07 +08:00
CodingOnStar
cb7be807fd fix: enable redesigned marketplace route 2026-08-10 10:43:07 +08:00
CodingOnStar
dc4c82c006 fix: align marketplace homepage with standalone design 2026-08-10 10:43:07 +08:00
CodingOnStar
a866e2e2f3 feat: redesign marketplace homepage 2026-08-10 10:43:07 +08:00
autofix-ci[bot]
963410e562 [autofix.ci] apply automated fixes 2026-08-08 03:13:10 +00:00
fatelei
5e0d54d4ce fix: fix test issue 2026-08-08 11:08:50 +08:00
fatelei
94cf4c2957 fix: fix frontend test 2026-08-08 10:36:43 +08:00
autofix-ci[bot]
aa43aee224 [autofix.ci] apply automated fixes 2026-08-08 01:11:20 +00:00
fatelei
9d1a3c5a00 Merge remote-tracking branch 'upstream/main' into skill 2026-08-08 09:06:56 +08:00
fatelei
ebe0a269ea fix: fix backend test 2026-08-08 09:06:36 +08:00
fatelei
edff7a4471 Merge remote-tracking branch 'upstream/main' into skill 2026-08-07 22:21:52 +08:00
autofix-ci[bot]
9009960b35 [autofix.ci] apply automated fixes 2026-08-07 12:57:53 +00:00
fatelei
9969c9f845 fix: fix conflict 2026-08-07 20:53:07 +08:00
fatelei
2dace438a3 Merge branch 'skill' into deploy/dev 2026-08-07 19:37:09 +08:00
fatelei
19a8683bca fix: fix skill name is untitled-skill and optimize ai logic 2026-08-07 19:35:44 +08:00
fatelei
154926d4ac Merge branch 'skill' into deploy/dev 2026-08-07 17:12:46 +08:00
fatelei
e740e6c423 fix: fix llm context 2026-08-07 17:10:36 +08:00
fatelei
703289fb0b Merge branch 'skill' into deploy/dev 2026-08-07 15:55:06 +08:00
fatelei
89219c479c fix: fix version restore 2026-08-07 15:54:24 +08:00
zxhlyh
3bbbdd478f Merge branch 'skill' into deploy/dev 2026-08-07 15:42:02 +08:00
zxhlyh
8bf624401a fix(skill): prevent builder send icon distortion 2026-08-07 15:37:24 +08:00
fatelei
9d5f87f0b4 Merge branch 'skill' into deploy/dev 2026-08-07 15:29:18 +08:00
fatelei
f8ca584a81 fix: fix some ui issue 2026-08-07 15:24:15 +08:00
zxhlyh
520999691f fix(skills): require display name to confirm deletion 2026-08-07 15:09:43 +08:00
fatelei
4b1f4f3223 Merge branch 'skill' into deploy/dev 2026-08-07 14:29:20 +08:00
fatelei
5cc0f16bae fix: fix publish date 2026-08-07 14:27:15 +08:00
fatelei
e9caaa43a3 Merge branch 'skill' into deploy/dev 2026-08-07 12:56:27 +08:00
fatelei
793300066b feat: add image upload limit 2026-08-07 12:53:19 +08:00
fatelei
8b456fb4df Merge branch 'skill' into deploy/dev 2026-08-07 11:49:02 +08:00
fatelei
6c052bb9b5 fix: fix some issue 2026-08-07 11:47:32 +08:00
zxhlyh
bc3ef4de71 fix(skills): align publish bar expansion 2026-08-07 11:22:34 +08:00
fatelei
a14b9be0be Merge branch 'skill' into deploy/dev 2026-08-06 17:38:45 +08:00
fatelei
f39e55caf6 chore: increase limit 2026-08-06 17:37:29 +08:00
fatelei
d5964397b2 Merge branch 'skill' into deploy/dev 2026-08-06 16:27:12 +08:00
fatelei
0065d0e0f1 chore: remove single file limit 2026-08-06 16:26:27 +08:00
zxhlyh
5053828fad feat(skills): align file editor previews 2026-08-06 14:25:57 +08:00
fatelei
fd38ac854c Merge branch 'skill' into deploy/dev 2026-08-05 17:41:40 +08:00
fatelei
3b033f9eb5 chore: upload zip skill use UPLOAD_SKILL_FILE_SIZE_LIMIT 2026-08-05 17:41:10 +08:00
zxhlyh
0451a321cb fix(skills): refresh list after version changes 2026-08-05 17:00:44 +08:00
zxhlyh
cfe627e894 fix(skills): refresh tag cache after creation 2026-08-05 15:33:44 +08:00
fatelei
47788f1617 Merge branch 'skill' into deploy/dev 2026-08-04 19:02:57 +08:00
fatelei
dcdf6715a3 feat: add rbac 2026-08-04 19:02:34 +08:00
zxhlyh
ea94659e7c feat(skills): add detail sidebar actions 2026-08-04 18:03:50 +08:00
zxhlyh
b3e0f6c5b7 fix(skills): pluralize upload failure copy 2026-08-04 16:54:10 +08:00
fatelei
4bdc28959d Merge branch 'skill' into deploy/dev 2026-08-04 16:34:01 +08:00
fatelei
3482166f78 fix: fix skill builder upload icon 2026-08-04 16:31:56 +08:00
fatelei
ff1c935761 Merge branch 'skill' into deploy/dev 2026-08-04 16:28:29 +08:00
fatelei
1a9c03c0af fix: fix some ui issue 2026-08-04 16:28:11 +08:00
zxhlyh
a43f2ccc0a fix(skills): correct dark theme surfaces 2026-08-04 15:50:31 +08:00
zxhlyh
4e00f708f7 fix(agent): allow removing embedded skills in build drafts 2026-08-04 14:54:37 +08:00
fatelei
c9a0aff2f9 Merge branch 'skill' into deploy/dev 2026-08-04 14:43:48 +08:00
fatelei
5afbf25a59 fix: fix ui issue 2026-08-04 14:43:25 +08:00
zxhlyh
b29abeafb5 feat(skills): align tag management behavior 2026-08-04 14:26:30 +08:00
fatelei
3a8676371c fix: fix conflict 2026-08-04 13:44:24 +08:00
fatelei
5a13489fdd fix: fix some bugs 2026-08-04 13:39:32 +08:00
zxhlyh
2f5fee324a fix(skills): refine empty state layout 2026-08-04 13:06:35 +08:00
fatelei
c9eb56c4cb fix: fix some ui issue 2026-08-04 11:45:25 +08:00
fatelei
6aa5804503 Merge branch 'skill' into deploy/dev 2026-08-04 11:27:39 +08:00
fatelei
bbba892849 fix: fix some ui issue 2026-08-04 11:27:24 +08:00
zxhlyh
62d95441d1 feat(skills): add file upload review workflow 2026-08-03 17:41:56 +08:00
zxhlyh
09479cafa4 fix(skills): refine detail loading skeleton 2026-08-03 17:41:56 +08:00
fatelei
26a5465c5b fix: fix ci failed 2026-08-03 17:09:43 +08:00
fatelei
64558c418c fix: fix ci failed 2026-08-03 16:56:55 +08:00
autofix-ci[bot]
785622f7f3 [autofix.ci] apply automated fixes 2026-08-03 08:16:10 +00:00
zxhlyh
43440c6edb fix(skills): link detail header navigation 2026-08-03 16:08:51 +08:00
fatelei
7a6c9934d7 Merge branch 'skill' into deploy/dev 2026-08-03 16:07:23 +08:00
fatelei
21a4e6fbcc feat: add file check api 2026-08-03 16:06:11 +08:00
zxhlyh
a4621041b6 feat(agent-v2): split skill add sources 2026-08-03 15:20:04 +08:00
autofix-ci[bot]
7c7d30fe1f [autofix.ci] apply automated fixes 2026-08-03 06:59:05 +00:00
fatelei
4ce6b1f620 Merge branch 'skill' into deploy/dev 2026-08-03 14:56:17 +08:00
fatelei
f0fb69f4c7 fix: fix some ui issue 2026-08-03 14:55:04 +08:00
zxhlyh
3ac6e1b115 feat(skills): refine versions and account navigation 2026-08-03 14:39:52 +08:00
zxhlyh
8767767794 fix(skills): match sidebar resize states 2026-08-03 11:56:58 +08:00
zxhlyh
a7ca830fad feat(skills): refine editor and builder parity 2026-08-03 10:49:26 +08:00
fatelei
acf56bf154 Merge branch 'skill' into deploy/dev 2026-08-03 09:54:13 +08:00
fatelei
63615b2fab fix: fix delete dialog 2026-08-03 09:52:24 +08:00
fatelei
7196a370a7 chore: increase test coverage 2026-08-02 22:23:32 +08:00
fatelei
f1f9e34683 chore: increase test coverage 2026-08-02 22:09:08 +08:00
fatelei
fa7a349a2a chore: increase test coverage 2026-08-02 21:43:24 +08:00
fatelei
ac902eb2e9 fix: fix test failed 2026-08-02 20:57:47 +08:00
autofix-ci[bot]
bbef12212f [autofix.ci] apply automated fixes 2026-08-02 12:08:38 +00:00
fatelei
b252c0834d fix: fix conflict 2026-08-02 20:04:29 +08:00
zxhlyh
8b908cbc43 fix(skills): complete metadata editor parity 2026-08-02 17:54:51 +08:00
zxhlyh
0aea96233d feat(skills): restore editor design parity 2026-08-02 17:51:29 +08:00
fatelei
977b2c4c93 fix: fix delete skill ref 2026-08-02 17:46:30 +08:00
fatelei
b441e3e838 feat: support agent skill 2026-08-02 17:28:21 +08:00
Harsh Kashyap
b91a3b8c27 fix(api): bound JinaReader and adaptive-crawl HTTP timeouts (#39859) (#39860)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-08-02 17:21:57 +08:00
Taranum Wasu
49695a7575 fix(api): make SSRF-blocked error message actionable (#39867)
Co-authored-by: Taranum01 <50813317+Taranum01@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-02 17:21:57 +08:00
csurong
654904fce0 fix(matrixone): apply score threshold to vector search (#39784)
Co-authored-by: caosurong <surong.cao@thinkingdata.cn>
2026-08-02 17:21:57 +08:00
kenwoodjw
2f1276864d fix(api): prevent cookies from shadowing admin API keys (#39872)
Signed-off-by: kenwoodjw <blackxin55+@gmail.com>
2026-08-02 17:21:57 +08:00
yyh
5ff641a49c refactor(web): align Base UI state consumers (#39864)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-02 17:21:57 +08:00
Harsh Kashyap
75bb174719 fix(api): bound billing service HTTP timeouts (#39874) (#39875)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-08-02 17:19:28 +08:00
Jony
fe6375e39c chore(api): fix typos in config description and comment (#39870)
Co-authored-by: Jony <13896935+zyz619963502zyz@users.noreply.github.com>
2026-08-02 17:19:28 +08:00
WH-2099
9939df18f5 fix(api): scope segment batch deletes (#38217) 2026-08-02 17:19:28 +08:00
yyh
cac9be9d98 fix(web): align secondary sidebar help trigger (#39862) 2026-08-02 17:19:27 +08:00
Chester
e3350f7ada fix(auth): add bounded timeout to WaterCrawl credential validation (#39824) 2026-08-02 17:19:27 +08:00
Chester
7526ab3163 fix(rag): add bounded timeout to Notion extractor requests (#39833) 2026-08-02 17:19:27 +08:00
Chester
893fe3bb01 fix(rag): add bounded timeout to Firecrawl extractor requests (#39831) 2026-08-02 17:19:27 +08:00
Asuka Minato
851b01578e test: use SQLite sessions in services workflow (#39087) 2026-08-02 17:19:27 +08:00
Asuka Minato
765f1172d5 test: use SQLite sessions in core workflow (#39086) 2026-08-02 17:19:27 +08:00
Asuka Minato
6137c60651 test: use SQLite sessions in core callback_handler (#39081) 2026-08-02 17:19:27 +08:00
Asuka Minato
975b1a6303 test: use SQLite sessions in core repositories (#39078) 2026-08-02 17:19:27 +08:00
Asuka Minato
045b39c6d6 test: use SQLite sessions in core llm_generator (#39070) 2026-08-02 17:19:27 +08:00
Asuka Minato
385f2196a9 test: use SQLite sessions in core tools (#39068) 2026-08-02 17:19:27 +08:00
Asuka Minato
9f73e16373 test: use SQLite sessions in core ops (#39062)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-02 17:19:27 +08:00
Asuka Minato
cd48673c20 test: use SQLite sessions in core rag (#39055) 2026-08-02 17:19:27 +08:00
Asuka Minato
33a8d28d0d test: use SQLite sessions in services agent (#39054) 2026-08-02 17:19:26 +08:00
yyh
38a6567bad refactor(ui): simplify scroll area API (#39857) 2026-08-02 17:19:26 +08:00
yyh
1434758d42 fix(web): hide prompt log action in agent v2 logs (#39858) 2026-08-02 16:41:18 +08:00
fatelei
2941960545 Merge branch 'skill' into deploy/dev 2026-07-31 21:06:09 +08:00
zxhlyh
c50ec44bd8 feat(skills): restore editor design parity 2026-07-31 21:02:02 +08:00
非法操作
3a6b69bfe7 fix(web): skip auth validation for local file datasource (#39856) 2026-07-31 20:56:38 +08:00
Joel
78d43c6712 feat: prioritize configured tools in the agent prompt picker (#39850) 2026-07-31 20:56:38 +08:00
autofix-ci[bot]
fcf05b4b63 [autofix.ci] apply automated fixes 2026-07-31 12:52:10 +00:00
fatelei
58abd13450 fix: fix some bugs 2026-07-31 20:47:57 +08:00
fatelei
38b1a406bb fix: fix publish update button ui issue 2026-07-31 20:47:56 +08:00
zxhlyh
dd6a28ca1f Merge branch 'skill' into deploy/dev 2026-07-31 17:50:41 +08:00
zxhlyh
f2b0aaeb8f fix(skills): complete metadata editor parity 2026-07-31 17:49:28 +08:00
zxhlyh
977c6ca61f fix(skills): match metadata entry design 2026-07-31 17:49:28 +08:00
zxhlyh
a8879e7360 fix(skills): match add metadata button design 2026-07-31 17:49:28 +08:00
fatelei
80365aec68 fix: fix conflict 2026-07-31 17:46:43 +08:00
fatelei
b1b01630e5 fix: fix some ui issue 2026-07-31 17:44:16 +08:00
zxhlyh
ec2e511d0d Merge branch 'codex/skills-editor-design-parity' into skill 2026-07-31 17:24:13 +08:00
zxhlyh
003c7b4fa9 feat(skills): restore editor design parity 2026-07-31 17:15:51 +08:00
zxhlyh
5caa2eea03 feat(skills): refine publish workflow UI 2026-07-31 17:05:33 +08:00
fatelei
d326d38a0a fix: fix failed test 2026-07-31 15:58:46 +08:00
KVOJJJin
4275c24dc5 fix(workflow): remap snippet references (#39843) 2026-07-31 15:58:46 +08:00
Bond Zhu
542544d8eb fix(api): silence opentelemetry.context error reports to Sentry on cloud (#39841) 2026-07-31 15:58:45 +08:00
iridescentWen
f835f0c59c docs: fix typos in api comments and prompt example (#39838) 2026-07-31 15:58:45 +08:00
Xin Zhang
d6fd31c756 fix: refresh mcp server code by app id (#39828)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-31 15:58:45 +08:00
zxhlyh
0ef7bf929e fix(skills): refine detail interactions and rename flow 2026-07-31 15:53:11 +08:00
zxhlyh
64cb61f902 merge: skills detail layout fix 2026-07-31 15:02:32 +08:00
zxhlyh
779f57902e fix(skills): hide main navigation on detail pages 2026-07-31 15:02:22 +08:00
zxhlyh
4bfd7fd17c feat(skills): match file drag and upload interactions 2026-07-31 15:00:10 +08:00
林玮 (Jade Lin)
ce775fd3f9 feat(plugin): isolate from local packages
(cherry picked from commit e6b371c9daea6892cf3068a3966363be6bcf61f8)
2026-07-31 14:25:48 +08:00
JzoNg
a0a8ed7594 Merge branch 'fix/snippet-variable-remap' into deploy/dev 2026-07-31 13:57:47 +08:00
JzoNg
111260fa37 fix(workflow): remap snippet references 2026-07-31 13:55:53 +08:00
zxhlyh
f3853f48df refactor(skills): split skill detail page modules 2026-07-31 12:16:01 +08:00
zxhlyh
7fde7b7ca4 fix(skills): align detail page interactions 2026-07-31 11:53:47 +08:00
Escape0707
68c59ad047 test: exercise credential visibility with SQLite (#39827) 2026-07-31 11:37:04 +08:00
Parman Mohammadalizadeh
7e3514adae chore(api): consolidate chained .where() calls into single calls (#39819) 2026-07-31 11:37:04 +08:00
起岚
ffbde43b2c docs: remove trailing whitespace in docker/README.md (#39808) 2026-07-31 11:37:04 +08:00
fatelei
64b7451003 Merge branch 'skill' into deploy/dev 2026-07-31 11:18:33 +08:00
fatelei
3ec3242a66 fix: fix skill tag 2026-07-31 11:18:14 +08:00
fatelei
238887036b fix: fix conflict 2026-07-31 09:11:04 +08:00
autofix-ci[bot]
4ec9fb0a58 [autofix.ci] apply automated fixes 2026-07-30 15:01:47 +00:00
fatelei
51b668a2a6 feat: update publish button logic 2026-07-30 22:57:22 +08:00
zxhlyh
ce38c21253 fix(skills): align file tree interactions 2026-07-30 22:57:22 +08:00
zxhlyh
f5ba4a8e51 fix: block skill file editing before load 2026-07-30 22:57:22 +08:00
fatelei
52100bbb29 fix: fix create skill not reload 2026-07-30 22:57:22 +08:00
fatelei
65e6d72254 fix: fix test failed 2026-07-30 22:57:22 +08:00
fatelei
48b5dca16a fix: fix style check failed 2026-07-30 22:57:22 +08:00
zxhlyh
fbe4e586f6 fix(web): support library skills in agent prompts 2026-07-30 22:57:22 +08:00
zxhlyh
ca99dda275 fix(web): isolate refreshed main navigation icons 2026-07-30 22:57:22 +08:00
zxhlyh
b28d45bb2d fix(web): align agent skills interactions with design 2026-07-30 22:57:22 +08:00
autofix-ci[bot]
404f79f370 [autofix.ci] apply automated fixes 2026-07-30 22:57:22 +08:00
fatelei
5a002d8f6a fix: fix style check failed 2026-07-30 22:57:21 +08:00
fatelei
606d1d7926 chore: increase test coverage and fix skill duplicate 2026-07-30 22:57:21 +08:00
fatelei
277c1ddf9c fix: fix test failed 2026-07-30 22:57:21 +08:00
autofix-ci[bot]
d6fc092795 [autofix.ci] apply automated fixes 2026-07-30 22:57:21 +08:00
fatelei
3a422d4dde fix: fix test failed 2026-07-30 22:57:21 +08:00
zxhlyh
eb8cda6355 fix(web): block skill builder sends during upload 2026-07-30 22:57:21 +08:00
fatelei
c81c774b49 fix: fix file conflict 2026-07-30 22:57:21 +08:00
fatelei
b6431e82cd feat: agent can op file and fix version check 2026-07-30 22:57:21 +08:00
zxhlyh
9d3b0ddf58 fix(web): preserve IME composition in skill builder 2026-07-30 22:57:21 +08:00
zxhlyh
a6948b57e6 fix(web): protect skill bindings in version view 2026-07-30 22:57:21 +08:00
zxhlyh
4cc89a054b fix(web): paginate workspace skills list 2026-07-30 22:57:21 +08:00
autofix-ci[bot]
03b18bd4e3 [autofix.ci] apply automated fixes 2026-07-30 22:57:21 +08:00
fatelei
2907adb12e fix: fix db version 2026-07-30 22:57:20 +08:00
fatelei
d08ca0e069 fix: fix model not change 2026-07-30 22:57:20 +08:00
zxhlyh
ca22915bf1 fix(web): prevent duplicate skill autosaves 2026-07-30 22:57:20 +08:00
fatelei
a4430fd373 fix: fix model not update 2026-07-30 22:57:20 +08:00
fatelei
90e802d5e4 fix: fix failed test 2026-07-30 22:57:20 +08:00
fatelei
6113893a53 fix: fix skill not in agent 2026-07-30 22:57:20 +08:00
fatelei
5aea0b1489 fix: fix frontend issue and let draft check relax 2026-07-30 22:57:20 +08:00
fatelei
6d59f60f88 fix: fix ci failed 2026-07-30 22:57:20 +08:00
fatelei
a5a08455b2 fix: fix ci failed 2026-07-30 22:57:20 +08:00
fatelei
bc296aa440 fix: fix scroll and live mode can not enter 2026-07-30 22:57:20 +08:00
fatelei
d01d63275e fix: fix some bugs 2026-07-30 22:57:19 +08:00
fatelei
00e67c1db1 chore: adjust skill entrance 2026-07-30 22:57:19 +08:00
fatelei
946603c513 fix: fix delete skill ref 2026-07-30 22:57:19 +08:00
fatelei
6414d0a020 feat: support agent skill 2026-07-30 22:57:18 +08:00
fatelei
22cab8f4bd fix: fix conflict 2026-07-30 13:12:21 +08:00
fatelei
f08d5343d0 fix: fix create skill not reload 2026-07-30 13:00:08 +08:00
CodingOnStar
8c0fa26938 Merge branch 'feat/marketplace-homepage-redesign' into deploy/dev 2026-07-30 12:10:24 +08:00
fatelei
e07c1be932 fix: fix test failed 2026-07-30 11:52:08 +08:00
CodingOnStar
1e4ecbfe0a chore: remove detail dialog readme 2026-07-30 11:49:04 +08:00
CodingOnStar
fd242a37de feat: open marketplace details in dialog 2026-07-30 11:45:50 +08:00
fatelei
2d8440309d fix: fix style check failed 2026-07-30 11:38:27 +08:00
zxhlyh
4bdc2a3d71 fix(web): support library skills in agent prompts 2026-07-30 11:38:27 +08:00
zxhlyh
668b99fab7 fix(web): isolate refreshed main navigation icons 2026-07-30 11:38:27 +08:00
zxhlyh
14942e7f1c fix(web): align agent skills interactions with design 2026-07-30 11:38:27 +08:00
autofix-ci[bot]
c1ace7800b [autofix.ci] apply automated fixes 2026-07-30 11:38:27 +08:00
fatelei
9a602ab6d6 fix: fix style check failed 2026-07-30 11:38:27 +08:00
fatelei
36eddb6a6b chore: increase test coverage and fix skill duplicate 2026-07-30 11:38:27 +08:00
fatelei
340cf3a5e8 fix: fix test failed 2026-07-30 11:38:27 +08:00
autofix-ci[bot]
1e20db0758 [autofix.ci] apply automated fixes 2026-07-30 11:38:26 +08:00
fatelei
2c8f39c217 fix: fix test failed 2026-07-30 11:38:26 +08:00
zxhlyh
a87d0bed3a fix(web): block skill builder sends during upload 2026-07-30 11:38:26 +08:00
fatelei
c01167b1f4 fix: fix file conflict 2026-07-30 11:38:26 +08:00
fatelei
23b06d5710 feat: agent can op file and fix version check 2026-07-30 11:38:26 +08:00
zxhlyh
4731f0fd08 fix(web): preserve IME composition in skill builder 2026-07-30 11:38:26 +08:00
zxhlyh
cf791ff9f4 fix(web): protect skill bindings in version view 2026-07-30 11:38:26 +08:00
zxhlyh
2f2889f957 fix(web): paginate workspace skills list 2026-07-30 11:38:26 +08:00
autofix-ci[bot]
22ba24b6a4 [autofix.ci] apply automated fixes 2026-07-30 11:38:26 +08:00
fatelei
bbfbe3f94c fix: fix db version 2026-07-30 11:38:26 +08:00
fatelei
a8a081486b fix: fix model not change 2026-07-30 11:38:26 +08:00
zxhlyh
f966ada97b fix(web): prevent duplicate skill autosaves 2026-07-30 11:38:26 +08:00
fatelei
ba865c7c9c fix: fix model not update 2026-07-30 11:38:26 +08:00
fatelei
f72b30f5a2 fix: fix failed test 2026-07-30 11:38:26 +08:00
fatelei
30888bfcf9 fix: fix skill not in agent 2026-07-30 11:38:25 +08:00
fatelei
067e95435f fix: fix frontend issue and let draft check relax 2026-07-30 11:38:25 +08:00
fatelei
c0c0b22415 fix: fix ci failed 2026-07-30 11:38:25 +08:00
fatelei
b95dd9594e fix: fix ci failed 2026-07-30 11:38:25 +08:00
fatelei
fda3de1ec0 fix: fix scroll and live mode can not enter 2026-07-30 11:38:25 +08:00
fatelei
1c1fd3517c fix: fix some bugs 2026-07-30 11:38:25 +08:00
fatelei
7f162a924c chore: adjust skill entrance 2026-07-30 11:38:25 +08:00
fatelei
d3fcb6e045 fix: fix delete skill ref 2026-07-30 11:38:25 +08:00
fatelei
a3d17b5e86 feat: support agent skill 2026-07-30 11:38:25 +08:00
zxhlyh
e1595f4b55 Merge branch 'skill' into deploy/dev
# Conflicts:
#	api/services/skill_management_service.py
#	api/services/tag_service.py
#	api/tests/unit_tests/controllers/console/workspace/test_skills.py
#	api/tests/unit_tests/services/test_skill_management_service.py
#	packages/contracts/generated/api/console/tags/types.gen.ts
#	packages/contracts/generated/api/console/tags/zod.gen.ts
#	packages/contracts/generated/api/console/workspaces/types.gen.ts
#	packages/contracts/generated/api/console/workspaces/zod.gen.ts
#	web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx
#	web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx
#	web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx
#	web/app/components/main-nav/__tests__/index.spec.tsx
#	web/app/components/main-nav/routes.ts
#	web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx
#	web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx
#	web/features/skills/__tests__/detail-page.spec.tsx
#	web/features/skills/detail-page.tsx
2026-07-29 19:31:15 +08:00
zxhlyh
ae20b3b189 fix(web): support library skills in agent prompts 2026-07-29 19:21:46 +08:00
zxhlyh
cd05b1d3e3 fix(web): isolate refreshed main navigation icons 2026-07-29 18:16:02 +08:00
zxhlyh
e61d4ff85d fix(web): align agent skills interactions with design 2026-07-29 17:16:53 +08:00
autofix-ci[bot]
8b72e93067 [autofix.ci] apply automated fixes 2026-07-29 09:07:07 +00:00
fatelei
ceea0b0222 fix: fix style check failed 2026-07-29 17:01:10 +08:00
fatelei
137354df91 chore: increase test coverage and fix skill duplicate 2026-07-29 15:39:28 +08:00
fatelei
039ca228fa fix: fix test failed 2026-07-29 15:39:28 +08:00
autofix-ci[bot]
44f7a814c3 [autofix.ci] apply automated fixes 2026-07-29 15:39:28 +08:00
fatelei
d5e702894a fix: fix test failed 2026-07-29 15:39:27 +08:00
zxhlyh
33f8999c60 fix(web): block skill builder sends during upload 2026-07-29 15:39:27 +08:00
fatelei
f5f3c59d33 fix: fix file conflict 2026-07-29 15:39:27 +08:00
fatelei
a046f66b76 feat: agent can op file and fix version check 2026-07-29 15:39:27 +08:00
zxhlyh
05259faaf9 fix(web): preserve IME composition in skill builder 2026-07-29 15:39:27 +08:00
zxhlyh
6d815cb16c fix(web): protect skill bindings in version view 2026-07-29 15:39:27 +08:00
zxhlyh
7aa44930a7 fix(web): paginate workspace skills list 2026-07-29 15:39:27 +08:00
autofix-ci[bot]
4763a02d55 [autofix.ci] apply automated fixes 2026-07-29 15:39:27 +08:00
fatelei
d82c30da65 fix: fix db version 2026-07-29 15:39:26 +08:00
fatelei
fff55e65ea fix: fix model not change 2026-07-29 15:39:26 +08:00
zxhlyh
53fe349fa8 fix(web): prevent duplicate skill autosaves 2026-07-29 15:39:26 +08:00
fatelei
a05db83783 fix: fix model not update 2026-07-29 15:39:26 +08:00
fatelei
ac5cde8d1e fix: fix failed test 2026-07-29 15:39:26 +08:00
fatelei
2c40f2d3cd fix: fix skill not in agent 2026-07-29 15:39:26 +08:00
fatelei
a006cd91a2 fix: fix frontend issue and let draft check relax 2026-07-29 15:39:26 +08:00
fatelei
136eef8c70 fix: fix ci failed 2026-07-29 15:39:26 +08:00
fatelei
b510cdcc48 fix: fix ci failed 2026-07-29 15:39:25 +08:00
fatelei
64745080f7 fix: fix scroll and live mode can not enter 2026-07-29 15:39:25 +08:00
fatelei
b991a91b80 fix: fix some bugs 2026-07-29 15:39:25 +08:00
fatelei
5a042854bd chore: adjust skill entrance 2026-07-29 15:39:25 +08:00
fatelei
61cec2e1be fix: fix delete skill ref 2026-07-29 15:39:25 +08:00
fatelei
3589fb3efc feat: support agent skill 2026-07-29 15:39:24 +08:00
fatelei
59295c05bf fix: fix conflict 2026-07-29 15:25:26 +08:00
fatelei
3fd75f6bd5 chore: increase test coverage and fix skill duplicate 2026-07-29 14:49:54 +08:00
fatelei
da94a3e334 fix: fix test failed 2026-07-29 12:05:21 +08:00
autofix-ci[bot]
9c96d3d417 [autofix.ci] apply automated fixes 2026-07-29 03:40:27 +00:00
fatelei
f7dc60bf90 fix: fix test failed 2026-07-29 11:35:33 +08:00
zxhlyh
58f17aa5a8 fix(web): block skill builder sends during upload 2026-07-29 11:15:04 +08:00
fatelei
95f76e12ef fix: fix file conflict 2026-07-29 10:58:21 +08:00
fatelei
3933334a4d feat: agent can op file and fix version check 2026-07-29 10:58:21 +08:00
zxhlyh
59e68de46b fix(web): preserve IME composition in skill builder 2026-07-29 10:58:21 +08:00
zxhlyh
e1e20c9981 fix(web): protect skill bindings in version view 2026-07-29 10:58:21 +08:00
zxhlyh
1495ea9a8f fix(web): paginate workspace skills list 2026-07-29 10:58:21 +08:00
autofix-ci[bot]
1af973bd79 [autofix.ci] apply automated fixes 2026-07-29 10:58:21 +08:00
fatelei
15ac8b8954 fix: fix db version 2026-07-29 10:58:21 +08:00
fatelei
1cd7699660 fix: fix model not change 2026-07-29 10:58:21 +08:00
zxhlyh
d264aee057 fix(web): prevent duplicate skill autosaves 2026-07-29 10:58:20 +08:00
fatelei
8f24e8af40 fix: fix model not update 2026-07-29 10:58:20 +08:00
fatelei
f92e7339f4 fix: fix failed test 2026-07-29 10:58:20 +08:00
fatelei
7908dd69be fix: fix skill not in agent 2026-07-29 10:58:20 +08:00
fatelei
a5ff29eea5 fix: fix frontend issue and let draft check relax 2026-07-29 10:58:20 +08:00
fatelei
2fe4790722 fix: fix ci failed 2026-07-29 10:58:20 +08:00
fatelei
35ddd2b6f4 fix: fix ci failed 2026-07-29 10:58:20 +08:00
fatelei
94dfaae3d0 fix: fix scroll and live mode can not enter 2026-07-29 10:58:20 +08:00
fatelei
78154faa62 fix: fix some bugs 2026-07-29 10:58:20 +08:00
fatelei
1ff2a7c05e chore: adjust skill entrance 2026-07-29 10:58:19 +08:00
fatelei
bb22386e30 fix: fix delete skill ref 2026-07-29 10:58:19 +08:00
fatelei
18fa5392fe feat: support agent skill 2026-07-29 10:58:19 +08:00
fatelei
33af2faf83 Merge branch 'skill' into deploy/dev 2026-07-29 10:36:54 +08:00
fatelei
e9e2b6e2a9 fix: fix file conflict 2026-07-29 10:33:18 +08:00
CodingOnStar
61c43c95e5 chore: include marketplace app styles 2026-07-28 20:14:17 +08:00
CodingOnStar
9316880ecb fix: add hybrid SSR for embedded marketplace 2026-07-28 19:16:17 +08:00
CodingOnStar
e39a7b10de fix: isolate embedded marketplace client 2026-07-28 19:10:05 +08:00
fatelei
2bb01984ed fix: fix conflict 2026-07-28 19:03:11 +08:00
fatelei
77e41a37ee feat: agent can op file and fix version check 2026-07-28 18:36:06 +08:00
zxhlyh
79229b7ede fix(web): preserve IME composition in skill builder 2026-07-28 17:46:12 +08:00
CodingOnStar
fd20d4f536 fix: simplify marketplace home tabs 2026-07-28 16:57:20 +08:00
CodingOnStar
796f32baa2 fix: localize marketplace homepage copy 2026-07-28 16:52:40 +08:00
CodingOnStar
a605790a84 feat: add themed marketplace logo 2026-07-28 16:40:33 +08:00
zxhlyh
36ae648a63 fix(web): protect skill bindings in version view 2026-07-28 16:21:43 +08:00
CodingOnStar
59bf9019c9 feat: share marketplace home shell with templates 2026-07-28 16:05:30 +08:00
zxhlyh
c409d531da fix(web): paginate workspace skills list 2026-07-28 15:49:44 +08:00
CodingOnStar
5fef0cf07c fix: stabilize marketplace home sticky navigation 2026-07-28 15:40:05 +08:00
autofix-ci[bot]
511ca69e4e [autofix.ci] apply automated fixes 2026-07-28 07:17:11 +00:00
fatelei
4c7e60d7e7 fix: fix db version 2026-07-28 15:12:58 +08:00
CodingOnStar
068aced02e feat: complete marketplace trending carousel 2026-07-28 15:07:38 +08:00
fatelei
a9e163403d fix: fix model not change 2026-07-28 14:52:16 +08:00
zxhlyh
bc136b89f9 fix(web): prevent duplicate skill autosaves 2026-07-28 14:52:16 +08:00
fatelei
b1bd4f9a8b fix: fix model not update 2026-07-28 14:52:16 +08:00
fatelei
06d3927f05 fix: fix failed test 2026-07-28 14:52:16 +08:00
fatelei
5c3516cae8 fix: fix skill not in agent 2026-07-28 14:52:16 +08:00
fatelei
1c18d8ddbd fix: fix frontend issue and let draft check relax 2026-07-28 14:52:16 +08:00
fatelei
253dfe9351 fix: fix ci failed 2026-07-28 14:52:15 +08:00
fatelei
2d3999e984 fix: fix ci failed 2026-07-28 14:52:15 +08:00
fatelei
a431cc726d fix: fix scroll and live mode can not enter 2026-07-28 14:52:15 +08:00
fatelei
08493d2429 fix: fix some bugs 2026-07-28 14:52:15 +08:00
fatelei
8dd0969006 chore: adjust skill entrance 2026-07-28 14:52:15 +08:00
fatelei
2d80d3c35c fix: fix delete skill ref 2026-07-28 14:52:14 +08:00
fatelei
4da764904c feat: support agent skill 2026-07-28 14:46:34 +08:00
fatelei
5c4f4fd1ef Merge branch 'skill' into deploy/dev 2026-07-28 14:44:57 +08:00
fatelei
54843971ac fix: fix model not change 2026-07-28 14:43:05 +08:00
zxhlyh
5ec5d3aeb9 fix(web): prevent duplicate skill autosaves 2026-07-28 14:30:17 +08:00
fatelei
d661b53e49 fix: fix model not update 2026-07-28 14:10:49 +08:00
zxhlyh
fd1e777f85 Merge branch 'codex/fix-skill-autosave-conflict' into deploy/dev 2026-07-28 14:04:25 +08:00
zxhlyh
e5f77ce185 fix(web): prevent duplicate skill autosaves 2026-07-28 14:02:18 +08:00
CodingOnStar
60bc4eaf3c refactor: preserve marketplace home server rendering 2026-07-28 11:55:01 +08:00
CodingOnStar
208073e228 feat: align marketplace hero with redesign 2026-07-28 11:28:29 +08:00
FFXN
fa1904e7ee Merge remote-tracking branch 'origin/main'
# Conflicts:
#	api/tests/unit_tests/services/test_telemetry_service.py
2026-07-27 11:02:28 +08:00
fatelei
99929d1c16 fix: fix conflict 2026-07-27 08:12:58 +08:00
fatelei
c699aa11db fix: fix failed test 2026-07-26 23:42:50 +08:00
fatelei
06cd0b56ac fix: fix skill not in agent 2026-07-26 21:00:38 +08:00
fatelei
241a9e1fec fix: fix frontend issue and let draft check relax 2026-07-26 21:00:38 +08:00
fatelei
3d79689fb5 fix: fix ci failed 2026-07-26 20:59:56 +08:00
fatelei
9f050f7957 fix: fix ci failed 2026-07-26 20:59:56 +08:00
fatelei
cb691d47d3 fix: fix scroll and live mode can not enter 2026-07-26 20:57:29 +08:00
fatelei
04f5ee58d0 fix: fix some bugs 2026-07-26 20:57:29 +08:00
fatelei
f5cff724f4 chore: adjust skill entrance 2026-07-26 20:57:29 +08:00
fatelei
1f4ccafdea fix: fix delete skill ref 2026-07-26 20:57:29 +08:00
fatelei
163049f6ca feat: support agent skill 2026-07-26 20:54:32 +08:00
CodingOnStar
895af9c1d1 fix: restore marketplace category icons 2026-07-24 18:31:00 +08:00
CodingOnStar
2bec40d7be feat: add sticky marketplace home navigation 2026-07-24 18:23:05 +08:00
FFXN
9fe9aa5157 Merge remote-tracking branch 'origin/main' 2026-07-24 18:00:47 +08:00
CodingOnStar
6d18502041 fix: restore marketplace home filter styles 2026-07-24 17:48:05 +08:00
CodingOnStar
e1432889cb fix: align marketplace home filters to left 2026-07-24 17:34:46 +08:00
CodingOnStar
0d65a338ef feat: add marketplace home catalog filters 2026-07-24 17:10:12 +08:00
CodingOnStar
3f2fa4d7de fix: align marketplace brand typography 2026-07-24 16:06:48 +08:00
CodingOnStar
0a23c0c386 fix: align embedded marketplace header 2026-07-24 15:53:21 +08:00
CodingOnStar
49bd13248f fix: enable redesigned marketplace route 2026-07-24 15:47:59 +08:00
fatelei
65e7507ca3 Merge branch 'skill' into deploy/dev 2026-07-24 13:38:15 +08:00
fatelei
ba157f9604 fix: fix skill not in agent 2026-07-24 13:37:19 +08:00
CodingOnStar
c989c3c6ac fix: align marketplace homepage with standalone design 2026-07-24 13:26:41 +08:00
fatelei
0dc913630e fix: fix conflict 2026-07-24 13:18:31 +08:00
fatelei
875cd30b1f fix: fix frontend issue and let draft check relax 2026-07-24 13:11:18 +08:00
fatelei
aa4a32ae84 fix: fix ci failed 2026-07-24 11:13:44 +08:00
fatelei
8573e14777 fix: fix ci failed 2026-07-24 11:13:44 +08:00
fatelei
1855be234c fix: fix scroll and live mode can not enter 2026-07-24 11:13:44 +08:00
fatelei
9bb960ff12 fix: fix some bugs 2026-07-24 11:13:44 +08:00
fatelei
1c14c7d467 chore: adjust skill entrance 2026-07-24 11:13:44 +08:00
fatelei
61faec16ca fix: fix delete skill ref 2026-07-24 11:13:44 +08:00
fatelei
57c836e692 feat: support agent skill 2026-07-24 11:13:43 +08:00
CodingOnStar
7d4718d8c5 Merge remote-tracking branch 'origin/main' into feat/marketplace-homepage-redesign
# Conflicts:
#	web/app/components/plugins/marketplace/index.tsx
#	web/app/components/plugins/marketplace/list/card-wrapper.tsx
#	web/app/components/plugins/marketplace/utils.ts
2026-07-24 10:26:43 +08:00
CodingOnStar
3b9176f538 feat: redesign marketplace homepage 2026-07-23 18:57:28 +08:00
samzong
626cc282b1 fix(web): echo OAuth state on authorize redirect (#39459)
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-07-23 06:36:58 -04:00
fatelei
52624d54e3 fix: fix db migration 2026-07-23 16:28:27 +08:00
fatelei
5ce038ef92 fix: fix db migration 2026-07-23 16:25:33 +08:00
fatelei
30f4d4c0c6 fix: fix db migration 2026-07-23 16:04:09 +08:00
fatelei
510679a7d1 fix: fix db migration 2026-07-23 15:38:05 +08:00
FFXN
33a61e32ed Merge remote-tracking branch 'origin/main' 2026-07-23 15:33:31 +08:00
FFXN
2a7968d210 feat(telemetry): add community telemetry configuration and reporting 2026-07-23 15:32:49 +08:00
fatelei
9237f2a14a fix: fix conflict 2026-07-23 15:23:09 +08:00
fatelei
bd178c7b29 Merge branch 'skill' into deploy/dev 2026-07-23 14:38:04 +08:00
fatelei
d80947aa72 fix: fix scroll and live mode can not enter 2026-07-23 14:36:44 +08:00
FFXN
037b7f6b5f feat(telemetry): add community telemetry configuration and reporting 2026-07-23 14:18:09 +08:00
fatelei
1618c37d26 Merge branch 'skill' into deploy/dev 2026-07-23 14:17:52 +08:00
fatelei
701ab64462 fix: fix some bugs 2026-07-23 14:14:28 +08:00
FFXN
3e6c3ace7b Merge remote-tracking branch 'origin/main' 2026-07-23 11:56:26 +08:00
FFXN
f26812310f feat(telemetry): add community telemetry configuration and reporting 2026-07-23 11:55:42 +08:00
fatelei
b3298800e9 chore: adjust skill entrance 2026-07-22 16:30:38 +08:00
fatelei
0f1c6b3f78 chore: remove agent v2 feature 2026-07-22 16:26:00 +08:00
林玮 (Jade Lin)
9b4b246aad refactor(api): simplify batch cleanup refresh state
(cherry picked from commit 1ded32d949)
2026-07-22 14:49:11 +08:00
林玮 (Jade Lin)
1bd654a289 refactor(api): simplify vector cache refresh metric
(cherry picked from commit fa585cf134)
2026-07-22 14:49:11 +08:00
林玮 (Jade Lin)
fc70329bdb refactor(api): derive cleanup tenant from dataset
(cherry picked from commit b124ec86fc)
2026-07-22 14:49:11 +08:00
林玮 (Jade Lin)
cd8a82fbd4 fix(api): invalidate vector space cache after cleanup
(cherry picked from commit a07e5c6eb4)
2026-07-22 14:49:11 +08:00
fatelei
550cb7eff5 fix: fix db migration 2026-07-22 14:42:11 +08:00
fatelei
2e748c16e9 Merge branch 'skill' into deploy/dev 2026-07-22 14:17:08 +08:00
fatelei
577012b66d fix: fix delete skill ref 2026-07-22 14:11:55 +08:00
fatelei
251c324180 Merge branch 'skill' into deploy/dev 2026-07-22 12:59:22 +08:00
fatelei
865a618fd5 Revert "feat: support agent skill"
This reverts commit 991116990a.
2026-07-22 12:48:29 +08:00
fatelei
991116990a feat: support agent skill 2026-07-22 11:45:50 +08:00
fatelei
eb5d1da0e8 feat: support agent skill 2026-07-22 10:25:25 +08:00
FFXN
0a9f1d58bd feat(telemetry): add community telemetry configuration and reporting 2026-07-21 15:54:47 +08:00
FFXN
a1a2abb533 feat(telemetry): add community telemetry configuration and reporting 2026-07-21 15:45:26 +08:00
Joel
dcc660daa2 fix: not support jump to detail in marketplace 2026-07-21 13:49:18 +08:00
FFXN
3f3422c37f feat(telemetry): add community telemetry configuration and reporting 2026-07-20 14:55:10 +08:00
FFXN
ccbb556a8b Merge remote-tracking branch 'origin/main' 2026-07-20 14:43:08 +08:00
FFXN
ad2094c96f Merge remote-tracking branch 'origin/main' 2026-07-13 18:07:43 +08:00
fatelei
dce3b7a7fc feat: use celery task 2026-07-10 15:20:06 +08:00
fatelei
be386aba3b chore: add switch 2026-07-10 15:19:56 +08:00
JzoNg
02e51e7d7c Merge branch 'main' into deploy/dev 2026-07-10 10:28:07 +08:00
Xiyuan Chen
96b6d4f2c0 Merge branch 'fix/socketio-user-connect-app-context' into deploy/dev 2026-07-08 22:02:43 -07:00
GareArc
4c84c5957d fix: run user_connect authorization inside Flask app context
Socket.IO event handlers run in plain gevent greenlets without a Flask
application context, so the db.session() call added in #38227 raises
RuntimeError: Working outside of application context, and every
user_connect is rejected as unauthorized. Wrap the authorization call in
sio.app.app_context(), matching the existing connect handler.
2026-07-08 21:57:07 -07:00
CodingOnStar
34613ecdc5 fix(app): update app creation logic to use database session and ensure RBAC access initialization 2026-07-09 12:00:16 +08:00
CodingOnStar
6a14245401 Merge remote-tracking branch 'origin/main' into deploy/dev 2026-07-09 11:49:16 +08:00
CodingOnStar
a758ca2aef fix(amplitude): update API key for amplitude initialization 2026-07-09 11:37:28 +08:00
CodingOnStar
9e60d4e213 Merge branch 'fix/report-slug' into deploy/dev 2026-07-09 11:29:04 +08:00
CodingOnStar
ef29c8442c refactor(amplitude): comment out amplitude initialization logic for future implementation 2026-07-09 11:26:59 +08:00
CodingOnStar
a1b45415ac fix(web): preserve attribution from auth redirect 2026-07-09 11:00:37 +08:00
fatelei
7fc46d75bd chore: add switch 2026-07-08 18:08:02 +08:00
fatelei
953a4ef0ca feat: create app sync scope and workspace user 2026-07-08 16:12:40 +08:00
FFXN
0f77b623d8 Merge remote-tracking branch 'origin/main' 2026-07-07 15:15:08 +08:00
FFXN
87e7e1ca2d chore: improve error message for missing cherry-pick provenance. 2026-07-07 12:13:18 +08:00
FFXN
796328e9f2 Merge remote-tracking branch 'origin/main' 2026-07-07 11:54:54 +08:00
FFXN
526059faeb Merge remote-tracking branch 'origin/main' 2026-07-03 16:20:52 +08:00
FFXN
272bdeb555 feat: enhance SQL query safety and add metadata key validation 2026-07-02 15:37:14 +08:00
126 changed files with 6128 additions and 207 deletions

View File

@@ -56,6 +56,7 @@ from . import (
knowledge_fs_proxy,
notification,
onboarding,
resource_access_token,
setup,
spec,
system,
@@ -234,6 +235,7 @@ __all__ = [
"rag_pipeline_workflow",
"rbac",
"recommended_app",
"resource_access_token",
"saved_message",
"setup",
"site",

View File

@@ -0,0 +1,256 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from flask_restx import Resource
from pydantic import Field, field_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
model_validate,
setup_required,
with_current_tenant_id,
with_current_user,
)
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models import Account
from models.account import TenantAccountRole
from models.resource_access_token import ResourceAccessTokenResourceType
from services.resource_access_token_service import (
ResourceAccessTokenResource,
ResourceAccessTokenRow,
ResourceAccessTokenService,
)
class ResourceAccessTokenResourcePayload(ResponseModel):
type: ResourceAccessTokenResourceType
id: str
class ResourceAccessTokenCreatePayload(ResponseModel):
name: str = Field(min_length=1)
resources: list[ResourceAccessTokenResourcePayload] = Field(min_length=1)
class ResourceAccessTokenUpdatePayload(ResponseModel):
name: str = Field(min_length=1)
resources: list[ResourceAccessTokenResourcePayload] = Field(min_length=1)
class ResourceAccessTokenListQuery(ResponseModel):
page: int = Field(default=1, ge=1)
limit: int = Field(default=20, ge=1, le=100)
class ResourceAccessTokenRowResponse(ResponseModel):
token_id: str
relation_id: str
name: str
track_id: str
token: str | None = None
masked_token: str
resource_type: ResourceAccessTokenResourceType
resource_id: str
resource_name: str
created_at: int
last_used_at: int | None = None
@field_validator("created_at", "last_used_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class ResourceAccessTokenListResponse(ResponseModel):
data: list[ResourceAccessTokenRowResponse]
page: int
limit: int
total: int
has_more: bool
class ResourceAccessTokenCreateResponse(ResponseModel):
token: str
data: list[ResourceAccessTokenRowResponse]
register_schema_models(
console_ns,
ResourceAccessTokenResourcePayload,
ResourceAccessTokenCreatePayload,
ResourceAccessTokenUpdatePayload,
ResourceAccessTokenListQuery,
)
register_response_schema_models(
console_ns,
ResourceAccessTokenRowResponse,
ResourceAccessTokenListResponse,
ResourceAccessTokenCreateResponse,
)
def _require_owner(current_user: Account) -> None:
if current_user.current_role != TenantAccountRole.OWNER:
raise Forbidden()
def _dump_rows(rows: list[ResourceAccessTokenRow]) -> list[dict[str, object]]:
return [
dump_response(
ResourceAccessTokenRowResponse,
ResourceAccessTokenRowResponse.model_validate(row, from_attributes=True),
)
for row in rows
]
@console_ns.route("/resource-access-tokens")
class ResourceAccessTokenListApi(Resource):
method_decorators = [account_initialization_required, login_required, setup_required]
@console_ns.doc("list_resource_access_tokens")
@console_ns.doc(params=query_params_from_model(ResourceAccessTokenListQuery))
@console_ns.response(200, "Resource access tokens", console_ns.models[ResourceAccessTokenListResponse.__name__])
@model_validate(ResourceAccessTokenListQuery)
@with_current_user
@with_current_tenant_id
@with_session(write=False)
def get(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
query: ResourceAccessTokenListQuery,
) -> dict[str, object]:
_require_owner(current_user)
rows = ResourceAccessTokenService.list_rows(
tenant_id=current_tenant_id,
page=query.page,
limit=query.limit,
session=session,
)
total = ResourceAccessTokenService.count_rows(tenant_id=current_tenant_id, session=session)
return dump_response(
ResourceAccessTokenListResponse,
{
"data": _dump_rows(rows),
"page": query.page,
"limit": query.limit,
"total": total,
"has_more": query.page * query.limit < total,
},
)
@console_ns.doc("create_resource_access_token")
@console_ns.expect(console_ns.models[ResourceAccessTokenCreatePayload.__name__])
@console_ns.response(
201,
"Resource access token created",
console_ns.models[ResourceAccessTokenCreateResponse.__name__],
)
@model_validate(ResourceAccessTokenCreatePayload)
@with_current_user
@with_current_tenant_id
@with_session
def post(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
payload: ResourceAccessTokenCreatePayload,
) -> tuple[dict[str, object], int]:
_require_owner(current_user)
result = ResourceAccessTokenService.create(
tenant_id=current_tenant_id,
created_by=current_user.id,
name=payload.name,
resources=[
ResourceAccessTokenResource(type=resource.type, id=resource.id) for resource in payload.resources
],
session=session,
)
return dump_response(
ResourceAccessTokenCreateResponse,
{"token": result.token, "data": _dump_rows(result.rows)},
), 201
@console_ns.route("/resource-access-tokens/<uuid:token_id>")
class ResourceAccessTokenApi(Resource):
method_decorators = [account_initialization_required, login_required, setup_required]
@console_ns.doc("update_resource_access_token")
@console_ns.expect(console_ns.models[ResourceAccessTokenUpdatePayload.__name__])
@console_ns.response(
200,
"Resource access token updated",
console_ns.models[ResourceAccessTokenListResponse.__name__],
)
@model_validate(ResourceAccessTokenUpdatePayload)
@with_current_user
@with_current_tenant_id
@with_session
def patch(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
payload: ResourceAccessTokenUpdatePayload,
token_id: UUID,
) -> dict[str, object]:
_require_owner(current_user)
rows = ResourceAccessTokenService.update(
tenant_id=current_tenant_id,
token_id=str(token_id),
name=payload.name,
resources=[
ResourceAccessTokenResource(type=resource.type, id=resource.id) for resource in payload.resources
],
session=session,
)
return dump_response(
ResourceAccessTokenListResponse,
{
"data": _dump_rows(rows),
"page": 1,
"limit": len(rows),
"total": len(rows),
"has_more": False,
},
)
@console_ns.route("/resource-access-tokens/<uuid:token_id>/relations/<uuid:relation_id>")
class ResourceAccessTokenRelationApi(Resource):
method_decorators = [account_initialization_required, login_required, setup_required]
@console_ns.doc("delete_resource_access_token_relation")
@console_ns.response(204, "Resource access token relation deleted")
@with_current_user
@with_current_tenant_id
@with_session
def delete(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
token_id: UUID,
relation_id: UUID,
) -> tuple[str, int]:
_require_owner(current_user)
ResourceAccessTokenService.delete_relation(
tenant_id=current_tenant_id,
token_id=str(token_id),
relation_id=str(relation_id),
session=session,
)
return "", 204

View File

@@ -34,7 +34,7 @@ from controllers.openapi.auth.requirements import (
CheckSubject,
CheckWorkspaceMember,
)
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
from controllers.service_api.app.error import (
AppUnavailableError,
CompletionRequestError,
@@ -155,7 +155,7 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest, Session], Any]] = {
class AppRunApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),
@@ -197,7 +197,7 @@ class AppRunApi(Resource):
class AppRunTaskStopApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),
@@ -207,6 +207,11 @@ class AppRunTaskStopApi(Resource):
returns=(200, TaskStopResponse, "Task stopped"),
)
def post(self, ctx: Context, app_id: str, task_id: str):
if isinstance(ctx.subject, ResourceAccessSubject):
# A bound app must not let a machine token stop another caller's task.
owner = redis_client.get(AppQueueManager._generate_task_belong_cache_key(task_id))
if owner != f"end-user-{ctx.end_user.id}".encode():
raise NotFound("Task not found")
AppQueueManager.set_stop_flag_no_user_check(task_id)
GraphEngineManager(redis_client).send_stop_command(task_id)
return TaskStopResponse(result="success")

View File

@@ -32,7 +32,7 @@ from controllers.openapi.auth.requirements import (
CheckSubject,
CheckWorkspaceMember,
)
from controllers.openapi.auth.subjects import AccountSubject
from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject
from controllers.service_api.app.error import AppUnavailableError
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from libs.oauth_bearer import Scope
@@ -104,7 +104,7 @@ def build_app_describe_response(app: App, fields: set[str] | None, *, session: S
class AppDescribeApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_READ),
@@ -122,7 +122,7 @@ class AppDescribeApi(Resource):
class AppListApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
CheckScope(Scope.APPS_READ),
CheckWorkspaceMember(),
),
@@ -131,7 +131,7 @@ class AppListApi(Resource):
)
def get(self, ctx: Context, *, query: AppListQuery):
workspace_id = query.workspace_id
account_id = str(ctx.subject.account_id)
account_id = str(ctx.subject.account_id or ctx.subject.token_id)
empty = AppListResponse.build(page=query.page, limit=query.limit, total=0, items=[])
@@ -145,7 +145,7 @@ class AppListApi(Resource):
access_filter = (
resolve_app_access_filter(workspace_id, account_id, session=ctx.session)
if dify_config.RBAC_ENABLED
if dify_config.RBAC_ENABLED and isinstance(ctx.subject, AccountSubject)
else AppAccessFilter.unrestricted()
)
@@ -154,6 +154,10 @@ class AppListApi(Resource):
app: App | None = AppService.get_visible_app_by_id(str(parsed_uuid), ctx.session)
if app is None or str(app.tenant_id) != workspace_id:
return empty
if ctx.resource_app_ids is not None and (
str(app.id) not in ctx.resource_app_ids or app.status != AppStatus.NORMAL
):
return empty
if not _is_listable(app):
return empty
# Apply RBAC visibility to the UUID fast-path the same way the service
@@ -188,6 +192,9 @@ class AppListApi(Resource):
access_filter.apply_to_params(params)
if ctx.resource_app_ids is not None:
params.accessible_app_ids = list(ctx.resource_app_ids)
params.include_own_apps = False
pagination = AppService().get_paginate_apps(account_id, workspace_id, params, ctx.session)
if pagination is None:
return empty

View File

@@ -39,6 +39,7 @@ class Context:
subject: Subject
session: Session
view_args: Mapping[str, str]
resource_app_ids: frozenset[str] | None = field(default=None, init=False)
_app: App | None = field(default=None, init=False)
_workspace: Tenant | None = field(default=None, init=False)
_workspace_role: TenantAccountRole | None = field(default=None, init=False)

View File

@@ -17,8 +17,9 @@ from controllers.openapi.auth.requirements import (
Requirement,
ResolveCaller,
)
from controllers.openapi.auth.resource_access import CheckResourceAccess
from controllers.openapi.auth.spec import EndpointSpec
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, Subject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject, Subject
from enums import DeploymentEdition
from libs.oauth_bearer import AuthContext, reset_auth_ctx, set_auth_ctx
from models.account import Account
@@ -63,6 +64,10 @@ class AccountPipeline(Pipeline, serves=AccountSubject):
fixed = (ResolveCaller(),)
class ResourceAccessPipeline(Pipeline, serves=ResourceAccessSubject):
fixed = (CheckResourceAccess(), ResolveCaller())
class _RequiresEnterprise(Requirement):
"""A gate on the token kind, not on a route, so no endpoint declares it.
It runs after `authenticate` on purpose: a `dfoe_` string no row backs

View File

@@ -22,7 +22,7 @@ from controllers.common.rbac import RBACCheck, enforce_rbac_checks
from controllers.openapi._audit import emit_wrong_surface
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.loaders import load_app, load_caller, load_workspace, load_workspace_role
from controllers.openapi.auth.subjects import Subject
from controllers.openapi.auth.subjects import ResourceAccessSubject, Subject
from enums import DeploymentEdition
from libs.oauth_bearer import Scope
from models.account import TenantAccountRole
@@ -168,6 +168,9 @@ class CheckAppAccess(Requirement):
@override
def run(self, subject: Subject, ctx: Context, session: Session) -> None:
# Machine access is authorized by live resource bindings in its pipeline.
if isinstance(subject, ResourceAccessSubject):
return
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return
access_mode = self._access_mode(str(load_app(ctx).id))

View File

@@ -0,0 +1,91 @@
"""Resource-token authentication and live resource authorization for OpenAPI.
Machine credentials never inherit their creator's account or workspace role.
Re-read bindings on every request so revocation takes effect without cache lag.
"""
import uuid
from typing import override
from flask import request
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, Unauthorized
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.requirements import Rank, Requirement
from controllers.openapi.auth.subjects import Subject
from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from libs.oauth_bearer import AuthContext, TokenType, sha256_hex
from libs.rate_limit import enforce_bearer_rate_limit
from models.account import Tenant, TenantStatus
from models.enums import AppStatus
from models.model import App
from models.resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
def authenticate_resource_token(token: str) -> AuthContext:
token_hash = sha256_hex(token)
enforce_bearer_rate_limit(token_hash)
with session_factory.create_session() as session:
row = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.token == token))
if row is None:
raise Unauthorized("invalid_bearer")
token_id = uuid.UUID(str(row.id))
return AuthContext(
subject_email=None,
subject_issuer=None,
account_id=None,
client_id=None,
token_id=token_id,
token_type=TokenType.RESOURCE_ACCESS,
expires_at=None,
)
class CheckResourceAccess(Requirement):
# CheckSubject rejects unsupported routes first, then bindings are loaded
# before any app or workspace requirements can read their context.
rank = Rank.FIRST
@override
def run(self, subject: Subject, ctx: Context, session: Session) -> None:
row = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.id == str(subject.token_id)))
if row is None:
raise Unauthorized("invalid_bearer")
tenant = session.get(Tenant, row.tenant_id)
if tenant is None or tenant.status != TenantStatus.NORMAL:
raise Forbidden("workspace unavailable")
workspace_id = ctx.view_args.get("workspace_id") or request.args.get("workspace_id")
if workspace_id and workspace_id != str(tenant.id):
raise Forbidden("resource_not_authorized")
ctx._workspace = tenant
ctx.resource_app_ids = frozenset(
session.scalars(
select(App.id)
.join(ResourceAccessTokenRelation, ResourceAccessTokenRelation.app_id == App.id)
.where(
ResourceAccessTokenRelation.token_id == row.id,
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
App.tenant_id == tenant.id,
)
)
)
app_id = ctx.view_args.get("app_id")
if app_id:
try:
app_id = str(uuid.UUID(app_id))
except ValueError:
raise Forbidden("resource_not_authorized")
if app_id not in ctx.resource_app_ids:
raise Forbidden("resource_not_authorized")
app = session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant.id))
if app is None or app.status != AppStatus.NORMAL or not app.enable_api:
raise Forbidden("resource_not_authorized")
ctx._app = app
row.last_used_at = naive_utc_now()

View File

@@ -11,11 +11,18 @@ from configs import dify_config
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.pipelines import pipeline_for_subject
from controllers.openapi.auth.requirements import assert_license_valid
from controllers.openapi.auth.resource_access import authenticate_resource_token
from controllers.openapi.auth.spec import EndpointSpec
from controllers.openapi.auth.subjects import subject_from_auth
from core.db.session_factory import session_factory
from enums import DeploymentEdition
from libs.oauth_bearer import InvalidBearerError, assert_bearer_feature_enabled, extract_bearer, get_authenticator
from libs.oauth_bearer import (
InvalidBearerError,
TokenType,
assert_bearer_feature_enabled,
extract_bearer,
get_authenticator,
)
class AuthRouter:
@@ -34,21 +41,24 @@ class AuthRouter:
answers 404 before anything reveals whether the bearer was valid, and
an enterprise deployment's licence answers 403 before the missing-bearer
401. The licence is a fact about the deployment, not the route or the
caller, so this is the one place it is checked. The bearer feature flag
is the same kind of fact and answers 503 next.
caller, so this is the one place it is checked. The OAuth feature flag
answers 503 before OAuth authentication, but does not gate independent
resource credentials. Missing or unknown credentials still receive 503
when OAuth is disabled.
"""
if spec.edition is not None and dify_config.DEPLOYMENT_EDITION not in spec.edition:
raise NotFound()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
assert_license_valid()
assert_bearer_feature_enabled()
token = extract_bearer(request)
is_resource_token = token is not None and TokenType.for_token(token) is TokenType.RESOURCE_ACCESS
if not is_resource_token:
assert_bearer_feature_enabled()
if not token:
raise Unauthorized("bearer required")
try:
auth = get_authenticator().authenticate(token)
auth = authenticate_resource_token(token) if is_resource_token else get_authenticator().authenticate(token)
except InvalidBearerError:
# One answer for every rejection reason - unknown prefix, no live row,
# expired - so a caller cannot probe which one it hit. Same reasoning as

View File

@@ -146,5 +146,28 @@ class ExternalSsoSubject(Subject):
return str(account.id) if account is not None else None
class ResourceAccessSubject(Subject):
subject_type = SubjectType.RESOURCE_ACCESS
caller_role = CreatorUserRole.END_USER
webapp_modes = frozenset[WebAppAccessMode]()
@override
def resolve_caller(self, ctx: Context, session: Session) -> EndUser:
return EndUserService.get_or_create_end_user_by_type(
EndUserType.OPENAPI,
tenant_id=str(ctx.workspace.id),
app_id=str(ctx.app.id),
user_id=f"resource-token:{self.token_id}",
)
@override
def mounts_caller(self, ctx: Context) -> bool:
return route_has_app(ctx)
@override
def webapp_user_id(self, session: Session) -> str | None:
return None
def subject_from_auth(auth: AuthContext) -> Subject:
return _SUBJECT_CLASSES[auth.subject_type](auth)

View File

@@ -25,7 +25,7 @@ from controllers.openapi.auth.requirements import (
CheckSubject,
CheckWorkspaceMember,
)
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
from extensions.ext_application_services import application_services
from fields.file_fields import FileResponse
from libs.oauth_bearer import Scope
@@ -46,7 +46,7 @@ class AppFileUploadApi(Resource):
)
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),

View File

@@ -27,7 +27,7 @@ from controllers.openapi.auth.requirements import (
CheckWorkspaceMember,
Requirement,
)
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, Subject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject, Subject
from core.db.session_factory import session_factory
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
from extensions.ext_database import db
@@ -87,7 +87,7 @@ def _ensure_form_belongs_to_app(form, app_model: App) -> None:
class OpenApiWorkflowHumanInputFormApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),
@@ -112,7 +112,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
class OpenApiWorkflowHumanInputFormSubmitApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),

View File

@@ -30,7 +30,7 @@ from controllers.openapi.auth.requirements import (
CheckSubject,
CheckWorkspaceMember,
)
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
from core.app.apps.base_app_generator import BaseAppGenerator
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
@@ -55,7 +55,7 @@ class OpenApiWorkflowEventsApi(Resource):
@openapi_ns.doc(params=query_params_from_model(WorkflowEventsQuery))
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),

View File

@@ -43,7 +43,7 @@ from controllers.openapi.auth.requirements import (
CheckWorkspaceMember,
CheckWorkspaceRole,
)
from controllers.openapi.auth.subjects import AccountSubject
from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject
from enums import DeploymentEdition
from libs.oauth_bearer import Scope
from models import Account, Tenant, TenantAccountJoin
@@ -88,10 +88,19 @@ def _check_member_invite_quota(tenant_id: str) -> None:
@openapi_ns.route("/workspaces")
class WorkspacesApi(Resource):
@endpoint(
requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_READ)),
requirements=(CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), CheckScope(Scope.WORKSPACE_READ)),
returns=(200, WorkspaceListResponse, "Workspace list"),
)
def get(self, ctx: Context):
if isinstance(ctx.subject, ResourceAccessSubject):
tenant = ctx.workspace
return WorkspaceListResponse(
workspaces=[
WorkspaceSummaryResponse(
id=str(tenant.id), name=tenant.name, role="", status=tenant.status, current=True
)
]
)
rows = TenantService.get_workspaces_for_account(str(ctx.subject.account_id), session=ctx.session)
return WorkspaceListResponse(workspaces=list(starmap(_workspace_summary, rows)))

View File

@@ -34,6 +34,7 @@ from services import dataset_api_key_service
from services.api_token_service import ApiTokenCache, fetch_token_with_single_flight, record_token_usage
from services.end_user_service import EndUserService
from services.feature_service import FeatureService
from services.resource_access_token_service import ResourceAccessTokenService
logger = logging.getLogger(__name__)
@@ -110,9 +111,16 @@ def validate_app_token[**P, R](
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
api_token = validate_and_get_api_token("app")
app_model = db.session.get(App, api_token.app_id)
auth_token = peek_service_api_bearer_token()
if auth_token and ResourceAccessTokenService.is_resource_access_token(auth_token):
app_model = ResourceAccessTokenService.resolve_app_for_service_api(
token=auth_token,
requested_app_id=request.headers.get("X-Dify-App-ID"),
session=db.session(),
)
else:
api_token = validate_and_get_api_token("app")
app_model = db.session.get(App, api_token.app_id)
if not app_model:
raise Forbidden("The app no longer exists.")
@@ -318,8 +326,6 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
@wraps(view)
def decorated(*args: object, **kwargs: object) -> R:
api_token = validate_and_get_api_token("dataset")
# Flask may pass URL path parameters positionally, so inspect both kwargs and args.
dataset_id = kwargs.get("dataset_id")
@@ -332,17 +338,28 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
except Exception:
logger.exception("Failed to parse dataset_id from positional args")
# Per-knowledge-base scoping is expressed by DatasetApiTokenBinding rows:
# no rows -> the key can reach every dataset in its tenant (default / back-compat)
# N rows -> the key is limited to exactly those datasets
# A bound key may only call endpoints carrying one of its dataset ids; endpoints
# without a dataset id (e.g. list/create datasets) are rejected. The set is queried
# per request (not cached) so scope changes take effect immediately.
# db.session is Flask-SQLAlchemy's scoped_session proxy; cast so the plain-Session
# typed helper accepts it (runtime proxies every Session method through unchanged).
bound_dataset_ids = dataset_api_key_service.get_bound_dataset_ids(cast(Session, db.session), api_token.id)
if bound_dataset_ids and (not dataset_id or str(dataset_id) not in bound_dataset_ids):
raise Forbidden("The API key is not authorized to access this knowledge base.")
auth_token = peek_service_api_bearer_token()
if auth_token and ResourceAccessTokenService.is_resource_access_token(auth_token):
tenant_id = ResourceAccessTokenService.resolve_tenant_for_dataset_service_api(
token=auth_token,
dataset_id=str(dataset_id) if dataset_id else None,
session=db.session(),
)
else:
api_token = validate_and_get_api_token("dataset")
tenant_id = api_token.tenant_id
# Per-knowledge-base scoping is expressed by DatasetApiTokenBinding rows:
# no rows -> the key can reach every dataset in its tenant (default / back-compat)
# N rows -> the key is limited to exactly those datasets
# A bound key may only call endpoints carrying one of its dataset ids; endpoints
# without a dataset id (e.g. list/create datasets) are rejected. The set is queried
# per request (not cached) so scope changes take effect immediately.
# db.session is Flask-SQLAlchemy's scoped_session proxy; cast so the plain-Session
# typed helper accepts it (runtime proxies every Session method through unchanged).
bound_dataset_ids = dataset_api_key_service.get_bound_dataset_ids(cast(Session, db.session), api_token.id)
if bound_dataset_ids and (not dataset_id or str(dataset_id) not in bound_dataset_ids):
raise Forbidden("The API key is not authorized to access this knowledge base.")
if dataset_id:
dataset_id = str(dataset_id)
@@ -350,7 +367,7 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
select(Dataset)
.where(
Dataset.id == dataset_id,
Dataset.tenant_id == api_token.tenant_id,
Dataset.tenant_id == tenant_id,
)
.limit(1)
)
@@ -361,7 +378,7 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
tenant_account_join = db.session.execute(
select(Tenant, TenantAccountJoin).where(
Tenant.id == api_token.tenant_id,
Tenant.id == tenant_id,
TenantAccountJoin.tenant_id == Tenant.id,
TenantAccountJoin.role.in_(["owner"]),
Tenant.status == TenantStatus.NORMAL,
@@ -383,9 +400,9 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
if expects_bound_instance:
if not args:
raise TypeError("validate_dataset_token expected a bound resource instance.")
return view(args[0], api_token.tenant_id, *args[1:], **kwargs)
return view(args[0], tenant_id, *args[1:], **kwargs)
return view(api_token.tenant_id, *args, **kwargs)
return view(tenant_id, *args, **kwargs)
return decorated
@@ -401,15 +418,7 @@ def validate_and_get_api_token(scope: str | None = None):
The last_used_at field is updated asynchronously via Celery task
to avoid blocking the request.
"""
auth_header = request.headers.get("Authorization")
if auth_header is None or " " not in auth_header:
raise Unauthorized("Authorization header must be provided and start with 'Bearer'")
auth_scheme, auth_token = auth_header.split(None, 1)
auth_scheme = auth_scheme.lower()
if auth_scheme != "bearer":
raise Unauthorized("Authorization scheme must be 'Bearer'")
auth_token = extract_service_api_bearer_token()
# Try to get token from cache first
# Returns a CachedApiToken (plain Python object), not a SQLAlchemy model
@@ -425,6 +434,27 @@ def validate_and_get_api_token(scope: str | None = None):
return fetch_token_with_single_flight(auth_token, scope)
def extract_service_api_bearer_token() -> str:
auth_header = request.headers.get("Authorization")
if auth_header is None or " " not in auth_header:
raise Unauthorized("Authorization header must be provided and start with 'Bearer'")
auth_scheme, auth_token = auth_header.split(None, 1)
if auth_scheme.lower() != "bearer":
raise Unauthorized("Authorization scheme must be 'Bearer'")
return auth_token
def peek_service_api_bearer_token() -> str | None:
auth_header = request.headers.get("Authorization")
if auth_header is None or " " not in auth_header:
return None
auth_scheme, auth_token = auth_header.split(None, 1)
if auth_scheme.lower() != "bearer":
return None
return auth_token
class DatasetApiResource(Resource):
__apidoc__ = {"responses": DATASET_TOKEN_AUTH_RESPONSES}

View File

@@ -53,6 +53,7 @@ class SubjectType(StrEnum):
# not is keyed by its external issuer instead.
bound_to_account: bool
RESOURCE_ACCESS = ("resource_access", frozenset({Scope.WORKSPACE_READ, Scope.APPS_READ, Scope.APPS_RUN}), False)
ACCOUNT = ("account", frozenset({Scope.FULL}), True)
EXTERNAL_SSO = ("external_sso", frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}), False)
@@ -72,6 +73,7 @@ class TokenType(StrEnum):
prefix: str
subject: SubjectType
RESOURCE_ACCESS = ("resource_access", "sk-", SubjectType.RESOURCE_ACCESS)
OAUTH_ACCOUNT = ("oauth_account", "dfoa_", SubjectType.ACCOUNT)
OAUTH_EXTERNAL_SSO = ("oauth_external_sso", "dfoe_", SubjectType.EXTERNAL_SSO)

View File

@@ -0,0 +1,108 @@
"""add resource access token tables
Revision ID: a8c9e2f1b704
Revises: d8e4a6b1c902
Create Date: 2026-08-18 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
import models.types
def _is_pg(conn):
return conn.dialect.name == "postgresql"
# revision identifiers, used by Alembic.
revision = "a8c9e2f1b704"
down_revision = "d8e4a6b1c902"
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
if _is_pg(conn):
op.create_table(
"resource_access_tokens",
sa.Column("id", postgresql.UUID(), nullable=False),
sa.Column("tenant_id", postgresql.UUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("track_id", sa.String(length=32), nullable=False),
sa.Column("token", sa.String(length=255), nullable=False),
sa.Column("created_by", postgresql.UUID(), nullable=False),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP(0)"), nullable=False),
sa.PrimaryKeyConstraint("id", name="resource_access_token_pkey"),
)
op.create_table(
"resource_access_tokens_relation",
sa.Column("id", postgresql.UUID(), nullable=False),
sa.Column("token_id", postgresql.UUID(), nullable=False),
sa.Column("resource_type", sa.String(length=16), nullable=False),
sa.Column("app_id", postgresql.UUID(), nullable=True),
sa.Column("dataset_id", postgresql.UUID(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP(0)"), nullable=False),
sa.CheckConstraint(
"(resource_type = 'app' AND app_id IS NOT NULL AND dataset_id IS NULL) OR "
"(resource_type = 'knowledge' AND dataset_id IS NOT NULL AND app_id IS NULL)",
name="resource_access_token_relation_resource_check",
),
sa.PrimaryKeyConstraint("id", name="resource_access_token_relation_pkey"),
)
else:
op.create_table(
"resource_access_tokens",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("track_id", sa.String(length=32), nullable=False),
sa.Column("token", sa.String(length=255), nullable=False),
sa.Column("created_by", models.types.StringUUID(), nullable=False),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name="resource_access_token_pkey"),
)
op.create_table(
"resource_access_tokens_relation",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("token_id", models.types.StringUUID(), nullable=False),
sa.Column("resource_type", sa.String(length=16), nullable=False),
sa.Column("app_id", models.types.StringUUID(), nullable=True),
sa.Column("dataset_id", models.types.StringUUID(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.CheckConstraint(
"(resource_type = 'app' AND app_id IS NOT NULL AND dataset_id IS NULL) OR "
"(resource_type = 'knowledge' AND dataset_id IS NOT NULL AND app_id IS NULL)",
name="resource_access_token_relation_resource_check",
),
sa.PrimaryKeyConstraint("id", name="resource_access_token_relation_pkey"),
)
with op.batch_alter_table("resource_access_tokens", schema=None) as batch_op:
batch_op.create_index("resource_access_token_tenant_idx", ["tenant_id"], unique=False)
batch_op.create_index("resource_access_token_token_idx", ["token"], unique=True)
batch_op.create_index("resource_access_token_track_id_idx", ["track_id"], unique=True)
with op.batch_alter_table("resource_access_tokens_relation", schema=None) as batch_op:
batch_op.create_index("resource_access_token_relation_token_idx", ["token_id"], unique=False)
batch_op.create_index("resource_access_token_relation_app_idx", ["app_id"], unique=False)
batch_op.create_index("resource_access_token_relation_dataset_idx", ["dataset_id"], unique=False)
batch_op.create_unique_constraint(
"resource_access_token_relation_app_unique",
["token_id", "resource_type", "app_id"],
)
batch_op.create_unique_constraint(
"resource_access_token_relation_dataset_unique",
["token_id", "resource_type", "dataset_id"],
)
def downgrade():
op.drop_table("resource_access_tokens_relation")
op.drop_table("resource_access_tokens")

View File

@@ -0,0 +1,26 @@
"""restore deploy/dev alembic revision c8e7f6a5b4d3
Revision ID: c8e7f6a5b4d3
Revises: 89919253ca7a
Create Date: 2026-08-20 15:45:00.000000
The deploy/dev database is stamped at this revision. The original file
was an empty merge of skill/debug heads and later disappeared when
deploy/dev was rewritten. Keep the revision id so flask db upgrade can
locate the database, and parent it at remove_agent_drive — the last
shared revision that was actually applied — so later migrations still run.
"""
# revision identifiers, used by Alembic.
revision: str = "c8e7f6a5b4d3"
down_revision = "89919253ca7a"
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,21 @@
"""merge c8e7f6a5b4d3 onto current deploy/dev head
Revision ID: ae0c8e7b4d31
Revises: c8e7f6a5b4d3, 5578e028b2f2
Create Date: 2026-09-01 17:48:00.000000
"""
# revision identifiers, used by Alembic.
revision: str = "ae0c8e7b4d31"
down_revision = ("c8e7f6a5b4d3", "5578e028b2f2")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,21 @@
"""merge deploy/dev and main migration heads
Revision ID: f2a7c9e4b610
Revises: ae0c8e7b4d31, d8e4a6b1c902
Create Date: 2026-09-17 12:00:00.000000
"""
# revision identifiers, used by Alembic.
revision: str = "f2a7c9e4b610"
down_revision = ("ae0c8e7b4d31", "d8e4a6b1c902")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,21 @@
"""merge resource access token and deploy/dev migration heads
Revision ID: b6d4e8f2a901
Revises: a8c9e2f1b704, f2a7c9e4b610
Create Date: 2026-09-20 12:00:00.000000
"""
# revision identifiers, used by Alembic.
revision: str = "b6d4e8f2a901"
down_revision = ("a8c9e2f1b704", "f2a7c9e4b610")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -113,6 +113,11 @@ from .provider import (
TenantDefaultModel,
TenantPreferredModelProvider,
)
from .resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
from .skill import AgentSkillBinding, Skill, SkillDraftFile, SkillFileKind, SkillFileStorage, SkillVersion
from .snippet import CustomizedSnippet, SnippetType
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
@@ -249,6 +254,9 @@ __all__ = [
"ProviderQuotaType",
"ProviderType",
"RecommendedApp",
"ResourceAccessToken",
"ResourceAccessTokenRelation",
"ResourceAccessTokenResourceType",
"SavedMessage",
"Site",
"Skill",

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from uuid import uuid4
import sqlalchemy as sa
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from libs.datetime_utils import naive_utc_now
from .base import Base
from .types import EnumText, StringUUID
class ResourceAccessTokenResourceType(StrEnum):
APP = "app"
KNOWLEDGE = "knowledge"
class ResourceAccessToken(Base):
__tablename__ = "resource_access_tokens"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="resource_access_token_pkey"),
sa.Index("resource_access_token_tenant_idx", "tenant_id"),
sa.Index("resource_access_token_token_idx", "token", unique=True),
sa.Index("resource_access_token_track_id_idx", "track_id", unique=True),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
track_id: Mapped[str] = mapped_column(String(32), nullable=False)
token: Mapped[str] = mapped_column(String(255), nullable=False)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=naive_utc_now,
server_default=func.current_timestamp(),
)
class ResourceAccessTokenRelation(Base):
__tablename__ = "resource_access_tokens_relation"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="resource_access_token_relation_pkey"),
sa.CheckConstraint(
"(resource_type = 'app' AND app_id IS NOT NULL AND dataset_id IS NULL) OR "
"(resource_type = 'knowledge' AND dataset_id IS NOT NULL AND app_id IS NULL)",
name="resource_access_token_relation_resource_check",
),
sa.Index("resource_access_token_relation_token_idx", "token_id"),
sa.Index("resource_access_token_relation_app_idx", "app_id"),
sa.Index("resource_access_token_relation_dataset_idx", "dataset_id"),
sa.UniqueConstraint("token_id", "resource_type", "app_id", name="resource_access_token_relation_app_unique"),
sa.UniqueConstraint(
"token_id",
"resource_type",
"dataset_id",
name="resource_access_token_relation_dataset_unique",
),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
token_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
resource_type: Mapped[ResourceAccessTokenResourceType] = mapped_column(
EnumText(ResourceAccessTokenResourceType, length=16),
nullable=False,
)
app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
dataset_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=naive_utc_now,
server_default=func.current_timestamp(),
)

View File

@@ -51,6 +51,7 @@ from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService
from services.openapi.visibility import apply_openapi_gate, is_openapi_visible
from services.rbac_agent_access_service import initialize_agent_rbac_access
from services.resource_access_token_service import ResourceAccessTokenService
from services.system_feature_service import SystemFeatureService
from services.tag_service import TagService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
@@ -1173,6 +1174,7 @@ class AppService:
tenant_id=app.tenant_id,
app_id=app.id,
)
ResourceAccessTokenService.delete_relations_for_app(app_id=app.id, session=session)
session.delete(app)
session.commit()

View File

@@ -90,6 +90,7 @@ from services.external_knowledge_service import ExternalDatasetService
from services.feature_service import FeatureService
from services.file_service import FileService
from services.rag_pipeline.rag_pipeline import RagPipelineService
from services.resource_access_token_service import ResourceAccessTokenService
from services.tag_service import TagService
from services.vector_service import VectorService
from tasks.add_document_to_index_task import add_document_to_index_task
@@ -1365,6 +1366,7 @@ class DatasetService:
# silently degrade to unrestricted (access-all) once its last binding is gone.
dataset_api_key_service.delete_keys_scoped_only_to(session, str(dataset.id))
ResourceAccessTokenService.delete_relations_for_dataset(dataset_id=dataset.id, session=session)
session.delete(dataset)
session.commit()
return True

View File

@@ -0,0 +1,467 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from uuid import uuid4
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized
from libs.datetime_utils import naive_utc_now
from models.dataset import Dataset
from models.model import App
from models.resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
TOKEN_PREFIX = "sk-"
MASK_PREFIX_LENGTH = 8
MASK_SUFFIX_LENGTH = 4
@dataclass(frozen=True)
class ResourceAccessTokenResource:
type: ResourceAccessTokenResourceType
id: str
@dataclass(frozen=True)
class ResourceAccessTokenRow:
token_id: str
relation_id: str
name: str
track_id: str
token: str | None
masked_token: str
resource_type: ResourceAccessTokenResourceType
resource_id: str
resource_name: str
created_at: datetime
last_used_at: datetime | None
@dataclass(frozen=True)
class ResourceAccessTokenCreateResult:
token_id: str
token: str
rows: list[ResourceAccessTokenRow]
class ResourceAccessTokenService:
@classmethod
def create(
cls,
*,
tenant_id: str,
created_by: str,
name: str,
resources: list[ResourceAccessTokenResource],
session: Session,
) -> ResourceAccessTokenCreateResult:
name = cls._normalize_name(name)
if not resources:
raise BadRequest("At least one resource is required.")
token = ResourceAccessToken(
tenant_id=tenant_id,
name=name,
track_id=cls._generate_track_id(session),
token=cls._generate_token(session),
created_by=created_by,
)
session.add(token)
session.flush()
seen: set[tuple[ResourceAccessTokenResourceType, str]] = set()
for resource in resources:
key = (resource.type, resource.id)
if key in seen:
continue
seen.add(key)
cls._ensure_resource_available(tenant_id=tenant_id, resource=resource, session=session)
relation = ResourceAccessTokenRelation(
token_id=token.id,
resource_type=resource.type,
app_id=resource.id if resource.type == ResourceAccessTokenResourceType.APP else None,
dataset_id=resource.id if resource.type == ResourceAccessTokenResourceType.KNOWLEDGE else None,
)
session.add(relation)
session.commit()
rows = cls.list_rows(tenant_id=tenant_id, token_id=token.id, include_token=True, session=session)
return ResourceAccessTokenCreateResult(token_id=token.id, token=token.token, rows=rows)
@classmethod
def list_rows(
cls,
*,
tenant_id: str,
session: Session,
page: int = 1,
limit: int = 20,
token_id: str | None = None,
include_token: bool = False,
) -> list[ResourceAccessTokenRow]:
stmt = (
select(ResourceAccessToken, ResourceAccessTokenRelation, App.name, Dataset.name)
.join(ResourceAccessTokenRelation, ResourceAccessTokenRelation.token_id == ResourceAccessToken.id)
.outerjoin(App, App.id == ResourceAccessTokenRelation.app_id)
.outerjoin(Dataset, Dataset.id == ResourceAccessTokenRelation.dataset_id)
.where(ResourceAccessToken.tenant_id == tenant_id)
.order_by(ResourceAccessToken.created_at.desc(), ResourceAccessTokenRelation.created_at.desc())
)
if token_id:
stmt = stmt.where(ResourceAccessToken.id == token_id)
else:
token_ids = (
select(ResourceAccessToken.id)
.where(ResourceAccessToken.tenant_id == tenant_id)
.order_by(ResourceAccessToken.created_at.desc())
.offset(max(page - 1, 0) * limit)
.limit(limit)
)
stmt = stmt.where(ResourceAccessToken.id.in_(token_ids))
return [
cls._row_from_models(token, relation, app_name, dataset_name, include_token=include_token)
for token, relation, app_name, dataset_name in session.execute(stmt).all()
]
@classmethod
def count_rows(cls, *, tenant_id: str, session: Session) -> int:
return (
session.scalar(
select(func.count(ResourceAccessToken.id)).where(ResourceAccessToken.tenant_id == tenant_id)
)
or 0
)
@classmethod
def update(
cls,
*,
tenant_id: str,
token_id: str,
name: str,
resources: list[ResourceAccessTokenResource] | None = None,
session: Session,
) -> list[ResourceAccessTokenRow]:
token = cls._get_token(tenant_id=tenant_id, token_id=token_id, session=session)
token.name = cls._normalize_name(name)
if resources is not None:
if not resources:
raise BadRequest("At least one resource is required.")
seen: set[tuple[ResourceAccessTokenResourceType, str]] = set()
desired_resources: list[ResourceAccessTokenResource] = []
for resource in resources:
key = (resource.type, resource.id)
if key in seen:
continue
seen.add(key)
cls._ensure_resource_available(tenant_id=tenant_id, resource=resource, session=session)
desired_resources.append(resource)
existing_relations = session.scalars(
select(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.id)
).all()
existing_by_key = {cls._relation_key(relation): relation for relation in existing_relations}
desired_keys = {(resource.type, resource.id) for resource in desired_resources}
for key, relation in existing_by_key.items():
if key not in desired_keys:
session.delete(relation)
for resource in desired_resources:
key = (resource.type, resource.id)
if key in existing_by_key:
continue
session.add(
ResourceAccessTokenRelation(
token_id=token.id,
resource_type=resource.type,
app_id=resource.id if resource.type == ResourceAccessTokenResourceType.APP else None,
dataset_id=resource.id if resource.type == ResourceAccessTokenResourceType.KNOWLEDGE else None,
)
)
session.commit()
return cls.list_rows(tenant_id=tenant_id, token_id=token_id, session=session)
@classmethod
def delete_relation(cls, *, tenant_id: str, token_id: str, relation_id: str, session: Session) -> None:
token = cls._get_token(tenant_id=tenant_id, token_id=token_id, session=session)
relation = session.scalar(
select(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.id == relation_id,
ResourceAccessTokenRelation.token_id == token.id,
)
)
if relation is None:
raise NotFound("Resource access token relation not found.")
session.execute(
delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.id)
)
session.delete(token)
session.commit()
@classmethod
def delete_relations_for_app(cls, *, app_id: str, session: Session) -> None:
token_ids = cls._delete_relations(
resource_type=ResourceAccessTokenResourceType.APP,
resource_id_field=ResourceAccessTokenRelation.app_id,
resource_id=app_id,
session=session,
)
cls._delete_orphan_tokens(token_ids=token_ids, session=session)
@classmethod
def delete_relations_for_dataset(cls, *, dataset_id: str, session: Session) -> None:
token_ids = cls._delete_relations(
resource_type=ResourceAccessTokenResourceType.KNOWLEDGE,
resource_id_field=ResourceAccessTokenRelation.dataset_id,
resource_id=dataset_id,
session=session,
)
cls._delete_orphan_tokens(token_ids=token_ids, session=session)
@classmethod
def validate_app_access(cls, *, token: str, app: App, session: Session) -> ResourceAccessToken:
access_token = cls._get_by_token(token=token, session=session)
relation = session.scalar(
select(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.token_id == access_token.id,
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
ResourceAccessTokenRelation.app_id == app.id,
)
)
if relation is None:
raise Forbidden("Resource access token is not allowed to access this app.")
access_token.last_used_at = naive_utc_now()
session.commit()
return access_token
@classmethod
def resolve_app_for_service_api(
cls,
*,
token: str,
requested_app_id: str | None,
session: Session,
) -> App:
access_token = cls._get_by_token(token=token, session=session)
stmt = select(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.token_id == access_token.id,
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
)
if requested_app_id:
stmt = stmt.where(ResourceAccessTokenRelation.app_id == requested_app_id)
relations = session.scalars(stmt).all()
if not relations:
raise Forbidden("Resource access token is not allowed to access this app.")
if len(relations) > 1:
raise BadRequest("App ID is required when a resource access token is bound to multiple apps.")
app_id = relations[0].app_id
if app_id is None:
raise Forbidden("Resource access token is not allowed to access this app.")
app = session.get(App, app_id)
if app is None:
raise Forbidden("The app no longer exists.")
access_token.last_used_at = naive_utc_now()
session.commit()
return app
@classmethod
def validate_dataset_access(cls, *, token: str, dataset_id: str, session: Session) -> ResourceAccessToken:
access_token = cls._get_by_token(token=token, session=session)
relation = session.scalar(
select(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.token_id == access_token.id,
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.KNOWLEDGE,
ResourceAccessTokenRelation.dataset_id == dataset_id,
)
)
if relation is None:
raise Forbidden("Resource access token is not allowed to access this knowledge base.")
access_token.last_used_at = naive_utc_now()
session.commit()
return access_token
@classmethod
def resolve_tenant_for_dataset_service_api(
cls,
*,
token: str,
dataset_id: str | None,
session: Session,
) -> str:
access_token = cls._get_by_token(token=token, session=session)
stmt = select(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.token_id == access_token.id,
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.KNOWLEDGE,
)
if dataset_id:
stmt = stmt.where(ResourceAccessTokenRelation.dataset_id == dataset_id)
relation = session.scalar(stmt.limit(1))
if relation is None:
raise Forbidden("Resource access token is not allowed to access this knowledge base.")
access_token.last_used_at = naive_utc_now()
session.commit()
return access_token.tenant_id
@staticmethod
def is_resource_access_token(token: str) -> bool:
return token.startswith(TOKEN_PREFIX)
@staticmethod
def mask_token(token: str) -> str:
if len(token) <= MASK_PREFIX_LENGTH + MASK_SUFFIX_LENGTH:
return token
return f"{token[:MASK_PREFIX_LENGTH]}...{token[-MASK_SUFFIX_LENGTH:]}"
@classmethod
def _get_by_token(cls, *, token: str, session: Session) -> ResourceAccessToken:
access_token = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.token == token))
if access_token is None:
raise Unauthorized("Resource access token is invalid.")
return access_token
@classmethod
def _get_token(cls, *, tenant_id: str, token_id: str, session: Session) -> ResourceAccessToken:
token = session.scalar(
select(ResourceAccessToken).where(
ResourceAccessToken.id == token_id,
ResourceAccessToken.tenant_id == tenant_id,
)
)
if token is None:
raise NotFound("Resource access token not found.")
return token
@staticmethod
def _normalize_name(name: str) -> str:
normalized = name.strip()
if not normalized:
raise BadRequest("Name is required.")
return normalized
@classmethod
def _generate_token(cls, session: Session) -> str:
while True:
value = f"{TOKEN_PREFIX}{uuid4()}"
if not session.scalar(select(ResourceAccessToken.id).where(ResourceAccessToken.token == value)):
return value
@classmethod
def _generate_track_id(cls, session: Session) -> str:
while True:
value = uuid4().hex
if not session.scalar(select(ResourceAccessToken.id).where(ResourceAccessToken.track_id == value)):
return value
@staticmethod
def _ensure_resource_available(
*,
tenant_id: str,
resource: ResourceAccessTokenResource,
session: Session,
) -> None:
if resource.type == ResourceAccessTokenResourceType.APP:
app = session.scalar(select(App).where(App.id == resource.id, App.tenant_id == tenant_id))
if app is None:
raise NotFound("App not found.")
if app.status != "normal" or not app.enable_api:
raise Forbidden("App API access is not enabled.")
return
dataset = session.scalar(select(Dataset).where(Dataset.id == resource.id, Dataset.tenant_id == tenant_id))
if dataset is None:
raise NotFound("Knowledge base not found.")
if not dataset.enable_api:
raise Forbidden("Knowledge base API access is not enabled.")
@staticmethod
def _delete_relations(
*,
resource_type: ResourceAccessTokenResourceType,
resource_id_field,
resource_id: str,
session: Session,
) -> list[str]:
token_ids = session.scalars(
select(ResourceAccessTokenRelation.token_id).where(
ResourceAccessTokenRelation.resource_type == resource_type,
resource_id_field == resource_id,
)
).all()
if token_ids:
session.execute(
delete(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.resource_type == resource_type,
resource_id_field == resource_id,
)
)
return list(token_ids)
@staticmethod
def _delete_orphan_tokens(*, token_ids: list[str], session: Session) -> None:
for token_id in set(token_ids):
remaining_count = (
session.scalar(
select(func.count(ResourceAccessTokenRelation.id)).where(
ResourceAccessTokenRelation.token_id == token_id,
)
)
or 0
)
if remaining_count == 0:
session.execute(delete(ResourceAccessToken).where(ResourceAccessToken.id == token_id))
@staticmethod
def _relation_key(
relation: ResourceAccessTokenRelation,
) -> tuple[ResourceAccessTokenResourceType, str]:
resource_id = (
relation.app_id
if relation.resource_type == ResourceAccessTokenResourceType.APP
else relation.dataset_id
)
assert resource_id is not None
return relation.resource_type, resource_id
@classmethod
def _row_from_models(
cls,
token: ResourceAccessToken,
relation: ResourceAccessTokenRelation,
app_name: str | None,
dataset_name: str | None,
*,
include_token: bool,
) -> ResourceAccessTokenRow:
resource_id = (
relation.app_id
if relation.resource_type == ResourceAccessTokenResourceType.APP
else relation.dataset_id
)
assert resource_id is not None
return ResourceAccessTokenRow(
token_id=token.id,
relation_id=relation.id,
name=token.name,
track_id=token.track_id,
token=token.token if include_token else None,
masked_token=cls.mask_token(token.token),
resource_type=relation.resource_type,
resource_id=resource_id,
resource_name=app_name or dataset_name or "",
created_at=token.created_at,
last_used_at=token.last_used_at,
)

View File

@@ -52,6 +52,7 @@ from models.workflow import (
)
from repositories.factory import DifyAPIRepositoryFactory
from services.api_token_service import ApiTokenCache
from services.resource_access_token_service import ResourceAccessTokenService
logger = logging.getLogger(__name__)
@@ -66,6 +67,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
_delete_app_site(tenant_id, app_id)
_delete_app_mcp_servers(tenant_id, app_id)
_delete_app_api_tokens(tenant_id, app_id)
_delete_app_resource_access_token_relations(app_id)
_delete_installed_apps(tenant_id, app_id)
_delete_app_stars(tenant_id, app_id)
_delete_recommended_apps(tenant_id, app_id)
@@ -164,6 +166,12 @@ def _delete_app_api_tokens(tenant_id: str, app_id: str):
)
def _delete_app_resource_access_token_relations(app_id: str):
with session_factory.get_session_maker()() as session:
ResourceAccessTokenService.delete_relations_for_app(app_id=app_id, session=session)
session.commit()
def _delete_installed_apps(tenant_id: str, app_id: str):
def del_installed_app(session: Session, installed_app_id: str):
session.execute(

View File

@@ -0,0 +1,16 @@
from types import SimpleNamespace
import pytest
from werkzeug.exceptions import Forbidden
from controllers.console.resource_access_token import _require_owner
from models.account import TenantAccountRole
def test_require_owner_allows_owner():
_require_owner(SimpleNamespace(current_role=TenantAccountRole.OWNER))
def test_require_owner_rejects_non_owner():
with pytest.raises(Forbidden):
_require_owner(SimpleNamespace(current_role=TenantAccountRole.ADMIN))

View File

@@ -0,0 +1,347 @@
from contextlib import nullcontext
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy import delete
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, Unauthorized
from controllers.openapi.auth.requirements import CheckScope, CheckSubject
from controllers.openapi.auth.resource_access import authenticate_resource_token
from controllers.openapi.auth.router import subject_router
from controllers.openapi.auth.spec import EndpointSpec
from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject
from libs.oauth_bearer import Scope, TokenType
from models.account import Tenant
from models.enums import CreatorUserRole
from models.model import App, AppMode, AppStar, EndUser
from models.resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
pytestmark = pytest.mark.parametrize(
"sqlite_session",
[(Tenant, App, AppStar, ResourceAccessToken, ResourceAccessTokenRelation)],
indirect=True,
)
@pytest.fixture
def resource_fixture(sqlite_session: Session):
tenant = Tenant(name="Token workspace")
tenant.id = str(uuid4())
app = App(
id=str(uuid4()), tenant_id=tenant.id, name="Bound app", mode=AppMode.CHAT, enable_api=True, enable_site=False
)
token = ResourceAccessToken(
id=str(uuid4()), tenant_id=tenant.id, name="CLI", token="sk-test", track_id="test", created_by=str(uuid4())
)
relation = ResourceAccessTokenRelation(
token_id=token.id, resource_type=ResourceAccessTokenResourceType.APP, app_id=app.id
)
sqlite_session.add_all([tenant, app, token, relation])
sqlite_session.commit()
flask_app = Flask(__name__)
flask_app.login_manager = MagicMock()
with (
patch(
"controllers.openapi.auth.resource_access.session_factory.create_session",
side_effect=lambda: nullcontext(sqlite_session),
),
patch("controllers.openapi.auth.resource_access.enforce_bearer_rate_limit"),
patch(
"controllers.openapi.auth.subjects.EndUserService.get_or_create_end_user_by_type",
return_value=MagicMock(spec=EndUser),
) as end_user,
patch("controllers.openapi.auth.pipelines._mount_flask_login"),
patch("controllers.openapi.auth.router.assert_license_valid"),
):
yield flask_app, app.id, token.id, tenant.id, end_user
def call(flask_app, app_id=None, workspace_id=None, scope=Scope.APPS_RUN, allowed=None):
path = "/apps" + (f"/{app_id}" if app_id else "")
if workspace_id:
path += f"?workspace_id={workspace_id}"
with flask_app.test_request_context(path, headers={"Authorization": "Bearer sk-test"}):
from flask import request
request.view_args = {"app_id": app_id} if app_id else {}
@subject_router.guard(
EndpointSpec(
requirements=(
CheckSubject(allowed=(AccountSubject,) if allowed else (ResourceAccessSubject,)),
CheckScope(scope),
)
)
)
def view(*, ctx):
return ctx
return view()
def test_bound_app_uses_machine_end_user_without_account(resource_fixture):
flask_app, app_id, token_id, tenant_id, end_user = resource_fixture
data = call(flask_app, app_id)
assert data.subject.caller_role == CreatorUserRole.END_USER
assert data.subject.account_id is None
assert data.app.id == app_id
end_user.assert_called_once()
assert end_user.call_args.kwargs["user_id"] == f"resource-token:{token_id}"
assert end_user.call_args.kwargs["tenant_id"] == tenant_id
def test_rejects_unbound_app_before_creating_caller(resource_fixture):
flask_app, _, _, _, end_user = resource_fixture
with pytest.raises(Forbidden, match="resource_not_authorized"):
call(flask_app, str(uuid4()))
end_user.assert_not_called()
def test_rejects_other_workspace(resource_fixture):
flask_app, _, _, _, _ = resource_fixture
with pytest.raises(Forbidden, match="resource_not_authorized"):
call(flask_app, workspace_id=str(uuid4()), scope=Scope.APPS_READ)
def test_binding_revocation_is_immediate(resource_fixture, sqlite_session):
flask_app, app_id, token_id, _, _ = resource_fixture
call(flask_app, app_id)
sqlite_session.execute(delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token_id))
sqlite_session.commit()
with pytest.raises(Forbidden):
call(flask_app, app_id)
def test_token_revocation_is_immediate(resource_fixture, sqlite_session):
flask_app, app_id, token_id, _, _ = resource_fixture
call(flask_app, app_id)
sqlite_session.execute(delete(ResourceAccessToken).where(ResourceAccessToken.id == token_id))
sqlite_session.commit()
with pytest.raises(Unauthorized):
call(flask_app, app_id)
def test_disabled_app_is_rejected(resource_fixture, sqlite_session):
flask_app, app_id, _, _, _ = resource_fixture
sqlite_session.get(App, app_id).enable_api = False
sqlite_session.commit()
with pytest.raises(Forbidden):
call(flask_app, app_id)
def test_app_discovery_keeps_explicit_empty_binding_set(resource_fixture, sqlite_session):
flask_app, _, token_id, tenant_id, _ = resource_fixture
sqlite_session.execute(delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token_id))
sqlite_session.commit()
data = call(flask_app, workspace_id=tenant_id, scope=Scope.APPS_READ)
assert data.resource_app_ids == frozenset()
def test_account_only_routes_reject_resource_token(resource_fixture):
flask_app, _, _, _, _ = resource_fixture
with pytest.raises(Forbidden, match="unsupported_token_type"):
call(flask_app, scope=Scope.WORKSPACE_WRITE, allowed=frozenset({TokenType.OAUTH_ACCOUNT}))
def test_invalid_token_is_unauthorized(resource_fixture):
flask_app, _, _, _, _ = resource_fixture
with flask_app.test_request_context("/"):
with pytest.raises(Unauthorized):
authenticate_resource_token("sk-invalid")
def test_archived_workspace_is_rejected(resource_fixture, sqlite_session):
flask_app, app_id, _, tenant_id, _ = resource_fixture
sqlite_session.get(Tenant, tenant_id).status = "archive"
sqlite_session.commit()
with pytest.raises(Forbidden):
call(flask_app, app_id)
def test_resource_token_has_workspace_read_scope(resource_fixture):
flask_app, _, _, tenant_id, _ = resource_fixture
with flask_app.test_request_context("/"):
identity = authenticate_resource_token("sk-test")
assert identity.scopes == frozenset({Scope.WORKSPACE_READ, Scope.APPS_READ, Scope.APPS_RUN})
assert str(call(flask_app, scope=Scope.WORKSPACE_READ).workspace.id) == tenant_id
def test_workspace_list_returns_only_token_tenant(resource_fixture, sqlite_session):
from controllers.openapi.workspaces import WorkspacesApi
flask_app, _, _, tenant_id, _ = resource_fixture
other_tenant = Tenant(name="Other workspace")
other_tenant.id = str(uuid4())
sqlite_session.add(other_tenant)
sqlite_session.commit()
with flask_app.test_request_context("/workspaces", headers={"Authorization": "Bearer sk-test"}):
result, status = WorkspacesApi().get()
assert status == 200
assert len(result["workspaces"]) == 1
assert result["workspaces"][0] == {
"id": tenant_id,
"name": "Token workspace",
"role": "",
"status": "normal",
"current": True,
}
@pytest.mark.parametrize("endpoint", ["detail", "members", "switch"])
def test_other_workspace_endpoints_reject_resource_token(resource_fixture, endpoint):
from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspaceMembersApi, WorkspaceSwitchApi
flask_app, _, _, tenant_id, _ = resource_fixture
methods = {
"detail": WorkspaceByIdApi().get,
"members": WorkspaceMembersApi().get,
"switch": WorkspaceSwitchApi().post,
}
with flask_app.test_request_context("/workspaces", headers={"Authorization": "Bearer sk-test"}):
with pytest.raises(Forbidden, match="unsupported_token_type"):
methods[endpoint](workspace_id=tenant_id)
def test_stop_task_rejects_other_token_owner(resource_fixture):
import inspect
from werkzeug.exceptions import NotFound
from controllers.openapi.app_run import AppRunTaskStopApi
flask_app, app_id, _, _, end_user = resource_fixture
data = call(flask_app, app_id)
end_user.return_value.id = "machine-user"
with (
patch("controllers.openapi.app_run.redis_client") as redis,
patch("controllers.openapi.app_run.AppQueueManager.set_stop_flag_no_user_check") as stop,
patch("controllers.openapi.app_run.GraphEngineManager") as engine,
):
redis.get.return_value = b"end-user-other-user"
with pytest.raises(NotFound):
inspect.unwrap(AppRunTaskStopApi.post)(AppRunTaskStopApi(), data, app_id, "task")
stop.assert_not_called()
engine.assert_not_called()
redis.get.return_value = b"end-user-machine-user"
inspect.unwrap(AppRunTaskStopApi.post)(AppRunTaskStopApi(), data, app_id, "task")
stop.assert_called_once_with("task")
@pytest.mark.parametrize("search_unbound", [False, True])
def test_app_list_filters_unbound_apps_before_pagination(resource_fixture, sqlite_session, search_unbound):
import inspect
from controllers.openapi._models import AppListQuery
from controllers.openapi.apps import AppListApi
flask_app, app_id, _, tenant_id, _ = resource_fixture
unbound = App(
id=str(uuid4()), tenant_id=tenant_id, name="Unbound app", mode=AppMode.CHAT, enable_api=True, enable_site=False
)
sqlite_session.add(unbound)
sqlite_session.commit()
with flask_app.test_request_context("/"):
data = call(flask_app, workspace_id=tenant_id, scope=Scope.APPS_READ)
result = inspect.unwrap(AppListApi.get)(
AppListApi(),
data,
query=AppListQuery(workspace_id=tenant_id, name=unbound.id if search_unbound else None),
)
assert result.total == (0 if search_unbound else 1)
assert [app.id for app in result.data] == ([] if search_unbound else [app_id])
def test_resource_token_cannot_gain_workspace_write_scope(resource_fixture):
flask_app, _, _, _, end_user = resource_fixture
with pytest.raises(Forbidden, match="insufficient_scope"):
call(flask_app, scope=Scope.WORKSPACE_WRITE)
end_user.assert_not_called()
def test_cross_tenant_binding_does_not_authorize_app(resource_fixture, sqlite_session):
flask_app, app_id, _, _, end_user = resource_fixture
other_tenant = Tenant(name="Other workspace")
other_tenant.id = str(uuid4())
sqlite_session.add(other_tenant)
sqlite_session.get(App, app_id).tenant_id = other_tenant.id
sqlite_session.commit()
with pytest.raises(Forbidden, match="resource_not_authorized"):
call(flask_app, app_id)
end_user.assert_not_called()
def test_bound_app_describe_uses_new_endpoint_contract(resource_fixture):
from flask import request
from controllers.openapi.apps import AppDescribeApi
flask_app, app_id, _, _, _ = resource_fixture
with flask_app.test_request_context(f"/apps/{app_id}?fields=info", headers={"Authorization": "Bearer sk-test"}):
request.view_args = {"app_id": app_id}
result, status = AppDescribeApi().get(app_id=app_id)
assert status == 200
assert result["info"]["id"] == app_id
def test_successful_request_records_token_usage(resource_fixture, sqlite_session):
flask_app, _, token_id, _, _ = resource_fixture
assert sqlite_session.get(ResourceAccessToken, token_id).last_used_at is None
call(flask_app, scope=Scope.WORKSPACE_READ)
assert sqlite_session.get(ResourceAccessToken, token_id).last_used_at is not None
def test_resource_token_works_when_oauth_is_disabled(resource_fixture, config_overrides):
config_overrides(ENABLE_OAUTH_BEARER=False)
flask_app, app_id, _, _, _ = resource_fixture
with patch("controllers.openapi.auth.router.get_authenticator") as oauth:
ctx = call(flask_app, app_id)
assert ctx.app.id == app_id
oauth.assert_not_called()
@pytest.mark.parametrize("route", ["upload", "events", "form_get", "form_submit"])
@pytest.mark.parametrize("bound", [True, False], ids=["bound", "unbound"])
def test_run_companion_routes_enforce_resource_bindings(resource_fixture, route, bound):
from flask import request
from controllers.openapi.files import AppFileUploadApi
from controllers.openapi.human_input_form import (
OpenApiWorkflowHumanInputFormApi,
OpenApiWorkflowHumanInputFormSubmitApi,
)
from controllers.openapi.workflow_events import OpenApiWorkflowEventsApi
handlers = {
"upload": AppFileUploadApi.post,
"events": OpenApiWorkflowEventsApi.get,
"form_get": OpenApiWorkflowHumanInputFormApi.get,
"form_submit": OpenApiWorkflowHumanInputFormSubmitApi.post,
}
flask_app, app_id, _, _, end_user = resource_fixture
handler = MagicMock(return_value="admitted")
# Exercise the production endpoint's entire authorization pipeline without
# invoking file storage, workflow streaming, or form submission side effects.
guarded = subject_router.guard(handlers[route].__spec__)(handler)
with (
flask_app.test_request_context("/apps", headers={"Authorization": "Bearer sk-test"}),
patch("controllers.openapi.human_input_form.HumanInputService") as forms,
):
forms.return_value.get_form_by_token.return_value = None
request.view_args = {"app_id": app_id if bound else str(uuid4()), "form_token": "form"}
if bound:
assert guarded() == "admitted"
ctx = handler.call_args.kwargs["ctx"]
assert ctx.app.id == app_id
assert ctx.end_user is end_user.return_value
else:
with pytest.raises(Forbidden, match="resource_not_authorized"):
guarded()
handler.assert_not_called()
end_user.assert_not_called()

View File

@@ -101,7 +101,9 @@ def test_a_dead_licence_403s_an_unauthenticated_caller_on_every_route_in_enterpr
view()
def test_a_disabled_bearer_feature_503s_before_the_bearer_is_read(
@pytest.mark.parametrize("token", [None, "dfoa_test", "dfoe_test", "unknown"])
def test_a_disabled_oauth_feature_503s_before_authentication(
token: str | None,
app: Flask,
config_overrides: Callable[..., None],
monkeypatch: pytest.MonkeyPatch,
@@ -110,10 +112,11 @@ def test_a_disabled_bearer_feature_503s_before_the_bearer_is_read(
the device endpoints do instead of a 500 from the unbound authenticator.
"""
config_overrides(ENABLE_OAUTH_BEARER=False)
monkeypatch.setattr(f"{ROUTER}.extract_bearer", never_reached)
monkeypatch.setattr(f"{ROUTER}.get_authenticator", never_reached)
view = _guard(_nothing)
with app.test_request_context("/openapi/v1/account"):
headers = {"Authorization": f"Bearer {token}"} if token else {}
with app.test_request_context("/openapi/v1/account", headers=headers):
with pytest.raises(ServiceUnavailable, match="bearer_auth_disabled"):
view()

View File

@@ -58,7 +58,9 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, monkeypatch:
api = AppRunTaskStopApi()
with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1:stop", method="POST"):
result = api.post.__handler__(api, _SealableContext(), app_id="app-1", task_id="task-1")
result = api.post.__handler__(
api, _SealableContext(subject=SimpleNamespace()), app_id="app-1", task_id="task-1"
)
queue_mock.set_stop_flag_no_user_check.assert_called_once_with("task-1")
graph_instance.send_stop_command.assert_called_once_with("task-1")

View File

@@ -88,7 +88,7 @@ from controllers.openapi.auth.requirements import (
Requirement,
)
from controllers.openapi.auth.spec import EndpointSpec
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
from controllers.openapi.human_input_form import CheckFormSurface
from enums import DeploymentEdition, WebAppAccessMode
from libs.oauth_bearer import (
@@ -741,9 +741,12 @@ DECLARED: dict[str, tuple[Requirement, ...]] = {
"account.sessions.revoke_self": _REQ_ACCOUNT_FULL,
"account.sessions.list": _REQ_ACCOUNT_FULL,
"account.sessions.revoke_one": _REQ_ACCOUNT_FULL,
"apps.describe": _REQ_APP_DESCRIBE,
"apps.list": _REQ_ACCOUNT_APPS_READ_MEMBER,
"workspaces.list": _REQ_ACCOUNT_WORKSPACE_READ,
"apps.describe": (CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), *_REQ_APP_DESCRIBE[1:]),
"apps.list": (CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), *_REQ_ACCOUNT_APPS_READ_MEMBER[1:]),
"workspaces.list": (
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
*_REQ_ACCOUNT_WORKSPACE_READ[1:],
),
"workspaces.describe": _REQ_ACCOUNT_WORKSPACE_READ,
"workspaces.switch": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
"workspaces.members.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
@@ -754,12 +757,24 @@ DECLARED: dict[str, tuple[Requirement, ...]] = {
"app_dsl.import_confirm": _REQ_DSL_WORKSPACE,
"app_dsl.export": _REQ_DSL_APP,
"app_dsl.check_dependencies": _REQ_DSL_APP,
"app_run.run": _REQ_RUN,
"app_run.stop": _REQ_RUN,
"files.upload": _REQ_FILES,
"human_input_form.get": _REQ_RUN_FORM,
"human_input_form.submit": _REQ_RUN_FORM,
"workflow_events.stream": _REQ_RUN,
"app_run.run": (CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)), *_REQ_RUN[1:]),
"app_run.stop": (CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)), *_REQ_RUN[1:]),
"files.upload": (
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
*_REQ_FILES[1:],
),
"human_input_form.get": (
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
*_REQ_RUN_FORM[1:],
),
"human_input_form.submit": (
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
*_REQ_RUN_FORM[1:],
),
"workflow_events.stream": (
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
*_REQ_RUN[1:],
),
"permitted_external.list": _REQ_EXTERNAL_LIST,
"permitted_external.describe": _REQ_EXTERNAL_DESCRIBE,
}

View File

@@ -30,6 +30,11 @@ from models.account import TenantAccountRole
from models.dataset import Dataset, RateLimitLog
from models.enums import ApiTokenType
from models.model import ApiToken, App, AppMode, DatasetApiTokenBinding, IconType
from models.resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
from tests.unit_tests.config_override import config_overrides_context
@@ -223,6 +228,59 @@ class TestValidateAppToken:
assert result["app_id"] == app_model.id
assert account.current_tenant_id == tenant.id
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.current_app")
@pytest.mark.parametrize(
"sqlite_session",
[(App, ResourceAccessToken, ResourceAccessTokenRelation, Tenant, Account, TenantAccountJoin)],
indirect=True,
)
def test_resource_access_token_allows_bound_app(
self,
mock_current_app,
mock_user_logged_in,
app: Flask,
sqlite_session: Session,
):
_configure_current_app_mock(mock_current_app)
tenant, account, _ = _persist_workspace(sqlite_session)
app_model = _app_model(tenant_id=tenant.id)
access_token = ResourceAccessToken(
tenant_id=tenant.id,
name="Production integration",
track_id="a" * 32,
token="sk-00000000-0000-0000-0000-000000000000",
created_by=account.id,
)
sqlite_session.add_all([app_model, access_token])
sqlite_session.flush()
sqlite_session.add(
ResourceAccessTokenRelation(
token_id=access_token.id,
resource_type=ResourceAccessTokenResourceType.APP,
app_id=app_model.id,
dataset_id=None,
)
)
sqlite_session.commit()
@validate_app_token
def protected_view(app_model):
return {"success": True, "app_id": app_model.id}
with (
app.test_request_context(
"/",
method="GET",
headers={"Authorization": f"Bearer {access_token.token}"},
),
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
):
result = protected_view()
assert result["app_id"] == app_model.id
assert sqlite_session.get(ResourceAccessToken, access_token.id).last_used_at is not None
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_app_not_found_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
@@ -695,6 +753,62 @@ class TestValidateDatasetToken:
assert result["tenant_id"] == tenant.id
assert account.current_tenant_id == tenant.id
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.current_app")
@pytest.mark.parametrize(
"sqlite_session",
[(Dataset, ResourceAccessToken, ResourceAccessTokenRelation, Tenant, Account, TenantAccountJoin)],
indirect=True,
)
def test_resource_access_token_allows_bound_dataset(
self,
mock_current_app,
mock_user_logged_in,
app: Flask,
sqlite_session: Session,
):
_configure_current_app_mock(mock_current_app)
tenant, account, _ = _persist_workspace(sqlite_session)
dataset = Dataset(
id=str(uuid.uuid4()),
tenant_id=tenant.id,
name="Customer Support",
created_by=account.id,
enable_api=True,
)
access_token = ResourceAccessToken(
tenant_id=tenant.id,
name="Production integration",
track_id="b" * 32,
token="sk-00000000-0000-0000-0000-000000000001",
created_by=account.id,
)
sqlite_session.add_all([dataset, access_token])
sqlite_session.flush()
sqlite_session.add(
ResourceAccessTokenRelation(
token_id=access_token.id,
resource_type=ResourceAccessTokenResourceType.KNOWLEDGE,
app_id=None,
dataset_id=dataset.id,
)
)
sqlite_session.commit()
@validate_dataset_token
def protected_view(tenant_id, dataset_id=None):
return {"success": True, "tenant_id": tenant_id, "dataset_id": dataset_id}
with (
app.test_request_context("/", method="GET", headers={"Authorization": f"Bearer {access_token.token}"}),
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
):
result = protected_view(dataset_id=dataset.id)
assert result["tenant_id"] == tenant.id
assert result["dataset_id"] == dataset.id
assert sqlite_session.get(ResourceAccessToken, access_token.id).last_used_at is not None
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
def test_dataset_not_found_raises_not_found(self, mock_validate_token, app: Flask, sqlite_session: Session):

View File

@@ -0,0 +1,229 @@
from __future__ import annotations
from uuid import uuid4
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.dataset import Dataset
from models.model import App, AppMode, IconType
from models.resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
from services.resource_access_token_service import (
ResourceAccessTokenResource,
ResourceAccessTokenService,
)
def _workspace(session: Session) -> tuple[Tenant, Account]:
tenant = Tenant(name="Workspace")
tenant.id = str(uuid4())
owner = Account(name="Owner", email=f"owner-{tenant.id}@example.com")
owner.id = str(uuid4())
session.add_all(
[
tenant,
owner,
TenantAccountJoin(tenant_id=tenant.id, account_id=owner.id, role=TenantAccountRole.OWNER),
]
)
session.commit()
return tenant, owner
def _app(session: Session, tenant_id: str) -> App:
app = App(
id=str(uuid4()),
tenant_id=tenant_id,
name="Customer FAQ Bot",
mode=AppMode.CHAT,
icon_type=IconType.EMOJI,
icon="🤖",
icon_background="#ffffff",
enable_site=False,
enable_api=True,
)
session.add(app)
session.commit()
return app
def _dataset(session: Session, tenant_id: str, created_by: str) -> Dataset:
dataset = Dataset(
id=str(uuid4()),
tenant_id=tenant_id,
name="Customer Support",
created_by=created_by,
enable_api=True,
)
session.add(dataset)
session.commit()
return dataset
@pytest.mark.parametrize(
"sqlite_session",
[(Tenant, Account, TenantAccountJoin, App, Dataset, ResourceAccessToken, ResourceAccessTokenRelation)],
indirect=True,
)
def test_create_token_with_mixed_resources_returns_expanded_rows(sqlite_session: Session) -> None:
tenant, owner = _workspace(sqlite_session)
app = _app(sqlite_session, tenant.id)
dataset = _dataset(sqlite_session, tenant.id, owner.id)
result = ResourceAccessTokenService.create(
tenant_id=tenant.id,
created_by=owner.id,
name="Production integration",
resources=[
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=app.id),
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.KNOWLEDGE, id=dataset.id),
],
session=sqlite_session,
)
assert result.token.startswith("sk-")
assert len(result.rows) == 2
assert {row.resource_type for row in result.rows} == {
ResourceAccessTokenResourceType.APP,
ResourceAccessTokenResourceType.KNOWLEDGE,
}
assert {row.resource_id for row in result.rows} == {app.id, dataset.id}
assert {row.name for row in result.rows} == {"Production integration"}
@pytest.mark.parametrize(
"sqlite_session",
[(Tenant, Account, TenantAccountJoin, App, Dataset, ResourceAccessToken, ResourceAccessTokenRelation)],
indirect=True,
)
def test_list_paginates_and_counts_tokens_not_relations(sqlite_session: Session) -> None:
tenant, owner = _workspace(sqlite_session)
first_app = _app(sqlite_session, tenant.id)
second_app = _app(sqlite_session, tenant.id)
dataset = _dataset(sqlite_session, tenant.id, owner.id)
ResourceAccessTokenService.create(
tenant_id=tenant.id,
created_by=owner.id,
name="First integration",
resources=[ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=first_app.id)],
session=sqlite_session,
)
newest = ResourceAccessTokenService.create(
tenant_id=tenant.id,
created_by=owner.id,
name="Newest integration",
resources=[
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=second_app.id),
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.KNOWLEDGE, id=dataset.id),
],
session=sqlite_session,
)
rows = ResourceAccessTokenService.list_rows(tenant_id=tenant.id, page=1, limit=1, session=sqlite_session)
assert ResourceAccessTokenService.count_rows(tenant_id=tenant.id, session=sqlite_session) == 2
assert {row.token_id for row in rows} == {newest.token_id}
assert len(rows) == 2
@pytest.mark.parametrize(
"sqlite_session",
[(Tenant, Account, TenantAccountJoin, App, ResourceAccessToken, ResourceAccessTokenRelation)],
indirect=True,
)
def test_update_changes_all_expanded_rows(sqlite_session: Session) -> None:
tenant, owner = _workspace(sqlite_session)
first_app = _app(sqlite_session, tenant.id)
second_app = _app(sqlite_session, tenant.id)
created = ResourceAccessTokenService.create(
tenant_id=tenant.id,
created_by=owner.id,
name="Production integration",
resources=[
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=first_app.id),
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=second_app.id),
],
session=sqlite_session,
)
rows = ResourceAccessTokenService.update(
tenant_id=tenant.id,
token_id=created.token_id,
name="Internal integration",
session=sqlite_session,
)
assert {row.name for row in rows} == {"Internal integration"}
@pytest.mark.parametrize(
"sqlite_session",
[(Tenant, Account, TenantAccountJoin, App, Dataset, ResourceAccessToken, ResourceAccessTokenRelation)],
indirect=True,
)
def test_update_replaces_resources(sqlite_session: Session) -> None:
tenant, owner = _workspace(sqlite_session)
first_app = _app(sqlite_session, tenant.id)
second_app = _app(sqlite_session, tenant.id)
dataset = _dataset(sqlite_session, tenant.id, owner.id)
created = ResourceAccessTokenService.create(
tenant_id=tenant.id,
created_by=owner.id,
name="Production integration",
resources=[
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=first_app.id),
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.KNOWLEDGE, id=dataset.id),
],
session=sqlite_session,
)
rows = ResourceAccessTokenService.update(
tenant_id=tenant.id,
token_id=created.token_id,
name="Internal integration",
resources=[ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=second_app.id)],
session=sqlite_session,
)
assert {row.name for row in rows} == {"Internal integration"}
assert [(row.resource_type, row.resource_id) for row in rows] == [
(ResourceAccessTokenResourceType.APP, second_app.id)
]
assert sqlite_session.scalars(select(ResourceAccessTokenRelation)).one().app_id == second_app.id
@pytest.mark.parametrize(
"sqlite_session",
[(Tenant, Account, TenantAccountJoin, App, Dataset, ResourceAccessToken, ResourceAccessTokenRelation)],
indirect=True,
)
def test_delete_relation_removes_token_and_all_relations(sqlite_session: Session) -> None:
tenant, owner = _workspace(sqlite_session)
app = _app(sqlite_session, tenant.id)
dataset = _dataset(sqlite_session, tenant.id, owner.id)
created = ResourceAccessTokenService.create(
tenant_id=tenant.id,
created_by=owner.id,
name="Production integration",
resources=[
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.APP, id=app.id),
ResourceAccessTokenResource(type=ResourceAccessTokenResourceType.KNOWLEDGE, id=dataset.id),
],
session=sqlite_session,
)
ResourceAccessTokenService.delete_relation(
tenant_id=tenant.id,
token_id=created.token_id,
relation_id=created.rows[0].relation_id,
session=sqlite_session,
)
assert sqlite_session.scalar(select(ResourceAccessToken)) is None
assert sqlite_session.scalar(select(ResourceAccessTokenRelation)) is None

View File

@@ -115,6 +115,17 @@ See [`ARD.md`] for architecture patterns, scaffolding recipe, dev workflow.
## License
Apache-2.0.
[`ARD.md`]: ARD.md
## Environment token authentication
`DIFY_TOKEN` takes precedence over saved login credentials and is not persisted.
Use `DIFY_HOST` to select the server. Without `DIFY_TOKEN`, the normal saved-login
flow is used.
Resource access tokens can list their own workspace through `/workspaces`.
The CLI uses that workspace to query apps; only apps bound to the token are
returned. Tokens do not inherit an account role or gain access to workspace
details, members, or workspace switching.
[Dify]: https://dify.ai
[`ARD.md`]: ARD.md

View File

@@ -0,0 +1,47 @@
import type { ActiveContext } from './hosts'
import type { HttpClient } from '@/http/types'
import { AccountClient } from '@/api/account'
import { WorkspacesClient } from '@/api/workspaces'
import { getEnv } from '@/env/registry'
import { createHttpClient } from '@/http/client'
import { DEFAULT_HOST, openAPIBase, resolveHost } from '@/util/host'
/** Resolve a process-scoped credential without reading or saving a login session. */
export async function environmentTokenContext(
token: string,
retryAttempts: number,
): Promise<{ active: ActiveContext; http: HttpClient }> {
const rawHost = getEnv('DIFY_HOST')?.trim() || DEFAULT_HOST
// Local development over loopback needs no TLS bypass for remote hosts.
const loopback = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?=[:/]|$)/.test(rawHost)
const host = resolveHost({ raw: rawHost, insecure: loopback })
const http = createHttpClient({ baseURL: openAPIBase(host), bearer: token, retryAttempts })
if (token.startsWith('sk-')) {
const { workspaces } = await new WorkspacesClient(http).list()
return {
http,
active: {
host,
email: '',
ctx: { account: { email: '', name: '' }, workspace: workspaces[0] },
},
}
}
const identity = await new AccountClient(http).get()
const account = identity.account ?? { email: '', name: '' }
return {
http,
active: {
host,
email: account.email,
ctx: {
account,
workspace: identity.workspaces?.find((w) => w.id === identity.default_workspace_id),
external_subject:
identity.subject_type === 'external_sso'
? { email: identity.subject_email ?? '', issuer: identity.subject_issuer ?? '' }
: undefined,
},
},
}
}

View File

@@ -0,0 +1,66 @@
import type { DifyMock } from '@test/fixtures/dify-mock/server'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startMock } from '@test/fixtures/dify-mock/server'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { AppsClient } from '@/api/apps'
import { Registry } from '@/auth/hosts'
import { buildAuthedContext } from './authed-command'
let server: DifyMock | undefined
let dir: string | undefined
afterEach(async () => {
vi.unstubAllEnvs()
await server?.stop()
if (dir) await rm(dir, { recursive: true, force: true })
})
async function setup(token: string) {
server = await startMock({ scenario: token.startsWith('sk-') ? 'resource-token' : 'happy' })
dir = await mkdtemp(join(tmpdir(), 'difyctl-env-token-'))
vi.stubEnv('DIFY_CONFIG_DIR', dir)
vi.stubEnv('DIFY_HOST', server.url)
vi.stubEnv('DIFY_TOKEN', token)
}
const command = {
error: (message: string): never => {
throw new Error(message)
},
}
describe('DIFY_TOKEN authentication', () => {
it('bootstraps the token workspace and lists only bound apps without a login', async () => {
await setup('sk-test-resource')
const ctx = await buildAuthedContext(command, { retryFlag: 0 })
expect(ctx.active.ctx.workspace?.role).toBe('')
const apps = await new AppsClient(ctx.http).list({ workspaceId: ctx.active.ctx.workspace!.id })
expect(apps.data).toHaveLength(1)
expect(apps.data[0]?.workspace_id).toBe(ctx.active.ctx.workspace!.id)
expect((await Registry.load()).resolveActive()).toBeUndefined()
})
it('uses an OAuth environment token without a login', async () => {
await setup('dfoa_test')
const ctx = await buildAuthedContext(command, { retryFlag: 0 })
expect(ctx.active.ctx.account.email).not.toBe('')
expect((await Registry.load()).resolveActive()).toBeUndefined()
})
it('reports invalid tokens without falling back to login', async () => {
await setup('sk-invalid')
await expect(buildAuthedContext(command, { retryFlag: 0 })).rejects.toMatchObject({
httpStatus: 401,
})
})
})
it('ignores broken saved login state when DIFY_TOKEN is provided', async () => {
await setup('dfoa_test')
const { writeFile } = await import('node:fs/promises')
await writeFile(join(dir!, 'hosts.yml'), 'invalid: [')
const ctx = await buildAuthedContext(command, { retryFlag: 0 })
expect(ctx.active.ctx.account.email).not.toBe('')
})

View File

@@ -5,6 +5,7 @@ import type { HttpClient } from '@/http/types'
import type { TokenStore } from '@/store/token-store'
import type { IOStreams } from '@/sys/io/streams'
import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
import { environmentTokenContext } from '@/auth/environment-token'
import { notLoggedInError, Registry } from '@/auth/hosts'
import { loadAppInfoCache } from '@/cache/app-info'
import { loadNudgeStore } from '@/cache/nudge-store'
@@ -40,19 +41,28 @@ export async function buildAuthedContext(
opts: AuthedContextOptions,
): Promise<AuthedContext> {
const io = realStreams(opts.format ?? '')
const reg = await Registry.load()
const active = reg.resolveActive()
if (active === undefined) fail(cmd, opts, io)
const envToken = getEnv('DIFY_TOKEN')?.trim()
const reg = envToken ? Registry.empty() : await Registry.load()
const store = getTokenStore(reg.token_storage)
const bearer = await store.read(active.host, active.email)
if (bearer === '') fail(cmd, opts, io)
const { host, insecure } = activeHostInfo(active)
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts, insecure })
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
let active: ActiveContext
let http: HttpClient
if (envToken) {
const context = await environmentTokenContext(envToken, retryAttempts)
active = context.active
http = context.http
} else {
const stored = reg.resolveActive()
if (stored === undefined) fail(cmd, opts, io)
active = stored
const bearer = await store.read(active.host, active.email)
if (bearer === '') fail(cmd, opts, io)
const { host, insecure } = activeHostInfo(active)
http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts, insecure })
}
const { host, insecure } = activeHostInfo(active)
// Metadata cached for a logged-in account must not cross an environment-token boundary.
const cache = opts.withCache === true && !envToken ? await loadAppInfoCache() : undefined
// Hard gate: refuse a server too old for this difyctl (throws → exit 6).
// Cached per host (1h) so most commands don't re-probe. Then the soft nudge

View File

@@ -1,5 +1,6 @@
export type Scenario =
| 'happy'
| 'resource-token'
| 'sso'
| 'no-email'
| 'denied'

View File

@@ -142,7 +142,15 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
return
}
const auth = c.req.header('Authorization') ?? ''
if (!TOKEN_RE.test(auth)) return unauthorized()
const resourceToken = getScenario() === 'resource-token' && auth === 'Bearer sk-test-resource'
if (!TOKEN_RE.test(auth) && !resourceToken) return unauthorized()
if (
resourceToken &&
c.req.path !== '/openapi/v1/workspaces' &&
c.req.path !== '/openapi/v1/apps' &&
!c.req.path.startsWith(`/openapi/v1/apps/${APPS[0]!.id}`)
)
return c.json({ code: 'forbidden', message: 'resource_not_authorized', status: 403 }, 403)
const scenario = getScenario()
if (scenario === 'auth-expired') return unauthorized()
await next()
@@ -224,6 +232,20 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
app.get('/openapi/v1/workspaces', (c) => {
if (getScenario() === 'sso') return c.json({ workspaces: [] })
if (getScenario() === 'resource-token') {
const workspace = WORKSPACES.find((w) => w.id === APPS[0]!.workspace_id)!
return c.json({
workspaces: [
{
id: workspace.id,
name: workspace.name,
role: '',
status: workspace.status,
current: true,
},
],
})
}
return c.json({
workspaces: WORKSPACES.map((w) => ({
id: w.id,
@@ -243,6 +265,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
const name = c.req.query('name')
const workspaceId = c.req.query('workspace_id') ?? ACCOUNT.current_workspace_id
let filtered = APPS.filter((a) => a.workspace_id === workspaceId)
if (getScenario() === 'resource-token') filtered = filtered.filter((a) => a.id === APPS[0]!.id)
if (mode !== undefined && mode !== '') filtered = filtered.filter((a) => a.mode === mode)
if (tag !== undefined && tag !== '')
filtered = filtered.filter((a) => a.tags.some((t) => t.name === tag))

View File

@@ -47,6 +47,12 @@ export const e2eWebEnvOverrides = {
NEXT_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/console/api',
NEXT_PUBLIC_ENABLE_AGENT_V2: 'true',
NEXT_PUBLIC_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/api',
// NEXT_PUBLIC_* values are inlined into the client bundle at build time, so the
// marketplace prefix (e.g. the frozen fixture stub started by run-cucumber.ts)
// must reach both the build environment and the build stamp hash.
...(process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX
? { NEXT_PUBLIC_MARKETPLACE_API_PREFIX: process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX }
: {}),
} satisfies Record<string, string>
const formatCommand = (command: string, args: string[]) => [command, ...args].join(' ')

View File

@@ -55,6 +55,10 @@ export const contractLoaders = {
remoteFiles: () => import('./remote-files/orpc.gen').then(({ remoteFiles }) => ({ remoteFiles })),
resetPassword: () =>
import('./reset-password/orpc.gen').then(({ resetPassword }) => ({ resetPassword })),
resourceAccessTokens: () =>
import('./resource-access-tokens/orpc.gen').then(({ resourceAccessTokens }) => ({
resourceAccessTokens,
})),
ruleCodeGenerate: () =>
import('./rule-code-generate/orpc.gen').then(({ ruleCodeGenerate }) => ({ ruleCodeGenerate })),
ruleGenerate: () =>

View File

@@ -0,0 +1,89 @@
// This file is auto-generated by @hey-api/openapi-ts
import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdPath,
zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponse,
zGetResourceAccessTokensQuery,
zGetResourceAccessTokensResponse,
zPatchResourceAccessTokensByTokenIdBody,
zPatchResourceAccessTokensByTokenIdPath,
zPatchResourceAccessTokensByTokenIdResponse,
zPostResourceAccessTokensBody,
zPostResourceAccessTokensResponse,
} from './zod.gen'
export const delete_ = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'deleteResourceAccessTokensByTokenIdRelationsByRelationId',
path: '/resource-access-tokens/{token_id}/relations/{relation_id}',
successStatus: 204,
tags: ['console'],
})
.input(z.object({ params: zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdPath }))
.output(zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponse)
export const byRelationId = {
delete: delete_,
}
export const relations = {
byRelationId,
}
export const patch = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'patchResourceAccessTokensByTokenId',
path: '/resource-access-tokens/{token_id}',
tags: ['console'],
})
.input(
z.object({
body: zPatchResourceAccessTokensByTokenIdBody,
params: zPatchResourceAccessTokensByTokenIdPath,
}),
)
.output(zPatchResourceAccessTokensByTokenIdResponse)
export const byTokenId = {
patch,
relations,
}
export const get = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getResourceAccessTokens',
path: '/resource-access-tokens',
tags: ['console'],
})
.input(z.object({ query: zGetResourceAccessTokensQuery.optional() }))
.output(zGetResourceAccessTokensResponse)
export const post = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postResourceAccessTokens',
path: '/resource-access-tokens',
successStatus: 201,
tags: ['console'],
})
.input(z.object({ body: zPostResourceAccessTokensBody }))
.output(zPostResourceAccessTokensResponse)
export const resourceAccessTokens = {
get,
post,
byTokenId,
}
export const contract = {
resourceAccessTokens,
}

View File

@@ -0,0 +1,113 @@
// This file is auto-generated by @hey-api/openapi-ts
export type ClientOptions = {
baseUrl: `${string}://${string}/console/api` | (string & {})
}
export type ResourceAccessTokenListResponse = {
data: Array<ResourceAccessTokenRowResponse>
has_more: boolean
limit: number
page: number
total: number
}
export type ResourceAccessTokenCreatePayload = {
name: string
resources: Array<ResourceAccessTokenResourcePayload>
}
export type ResourceAccessTokenCreateResponse = {
data: Array<ResourceAccessTokenRowResponse>
token: string
}
export type ResourceAccessTokenUpdatePayload = {
name: string
resources: Array<ResourceAccessTokenResourcePayload>
}
export type ResourceAccessTokenRowResponse = {
created_at: number
last_used_at?: number | null
masked_token: string
name: string
relation_id: string
resource_id: string
resource_name: string
resource_type: ResourceAccessTokenResourceType
token?: string | null
token_id: string
track_id: string
}
export type ResourceAccessTokenResourcePayload = {
id: string
type: ResourceAccessTokenResourceType
}
export type ResourceAccessTokenResourceType = 'app' | 'knowledge'
export type GetResourceAccessTokensData = {
body?: never
path?: never
query?: {
limit?: number
page?: number
}
url: '/resource-access-tokens'
}
export type GetResourceAccessTokensResponses = {
200: ResourceAccessTokenListResponse
}
export type GetResourceAccessTokensResponse =
GetResourceAccessTokensResponses[keyof GetResourceAccessTokensResponses]
export type PostResourceAccessTokensData = {
body: ResourceAccessTokenCreatePayload
path?: never
query?: never
url: '/resource-access-tokens'
}
export type PostResourceAccessTokensResponses = {
201: ResourceAccessTokenCreateResponse
}
export type PostResourceAccessTokensResponse =
PostResourceAccessTokensResponses[keyof PostResourceAccessTokensResponses]
export type PatchResourceAccessTokensByTokenIdData = {
body: ResourceAccessTokenUpdatePayload
path: {
token_id: string
}
query?: never
url: '/resource-access-tokens/{token_id}'
}
export type PatchResourceAccessTokensByTokenIdResponses = {
200: ResourceAccessTokenListResponse
}
export type PatchResourceAccessTokensByTokenIdResponse =
PatchResourceAccessTokensByTokenIdResponses[keyof PatchResourceAccessTokensByTokenIdResponses]
export type DeleteResourceAccessTokensByTokenIdRelationsByRelationIdData = {
body?: never
path: {
relation_id: string
token_id: string
}
query?: never
url: '/resource-access-tokens/{token_id}/relations/{relation_id}'
}
export type DeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponses = {
204: void
}
export type DeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponse =
DeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponses[keyof DeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponses]

View File

@@ -0,0 +1,106 @@
// This file is auto-generated by @hey-api/openapi-ts
import * as z from 'zod'
/**
* ResourceAccessTokenResourceType
*/
export const zResourceAccessTokenResourceType = z.enum(['app', 'knowledge'])
/**
* ResourceAccessTokenRowResponse
*/
export const zResourceAccessTokenRowResponse = z.object({
created_at: z.int(),
last_used_at: z.int().nullish(),
masked_token: z.string(),
name: z.string(),
relation_id: z.string(),
resource_id: z.string(),
resource_name: z.string(),
resource_type: zResourceAccessTokenResourceType,
token: z.string().nullish(),
token_id: z.string(),
track_id: z.string(),
})
/**
* ResourceAccessTokenListResponse
*/
export const zResourceAccessTokenListResponse = z.object({
data: z.array(zResourceAccessTokenRowResponse),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* ResourceAccessTokenCreateResponse
*/
export const zResourceAccessTokenCreateResponse = z.object({
data: z.array(zResourceAccessTokenRowResponse),
token: z.string(),
})
/**
* ResourceAccessTokenResourcePayload
*/
export const zResourceAccessTokenResourcePayload = z.object({
id: z.string(),
type: zResourceAccessTokenResourceType,
})
/**
* ResourceAccessTokenUpdatePayload
*/
export const zResourceAccessTokenUpdatePayload = z.object({
name: z.string().min(1),
resources: z.array(zResourceAccessTokenResourcePayload).min(1),
})
/**
* ResourceAccessTokenCreatePayload
*/
export const zResourceAccessTokenCreatePayload = z.object({
name: z.string().min(1),
resources: z.array(zResourceAccessTokenResourcePayload).min(1),
})
export const zGetResourceAccessTokensQuery = z.object({
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* Resource access tokens
*/
export const zGetResourceAccessTokensResponse = zResourceAccessTokenListResponse
export const zPostResourceAccessTokensBody = zResourceAccessTokenCreatePayload
/**
* Resource access token created
*/
export const zPostResourceAccessTokensResponse = zResourceAccessTokenCreateResponse
export const zPatchResourceAccessTokensByTokenIdBody = zResourceAccessTokenUpdatePayload
export const zPatchResourceAccessTokensByTokenIdPath = z.object({
token_id: z.uuid(),
})
/**
* Resource access token updated
*/
export const zPatchResourceAccessTokensByTokenIdResponse = zResourceAccessTokenListResponse
export const zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdPath = z.object({
relation_id: z.uuid(),
token_id: z.uuid(),
})
/**
* Resource access token relation deleted
*/
export const zDeleteResourceAccessTokensByTokenIdRelationsByRelationIdResponse = z.void()

View File

@@ -37,6 +37,7 @@ import { rag } from './rag/orpc.gen'
import { refreshToken } from './refresh-token/orpc.gen'
import { remoteFiles } from './remote-files/orpc.gen'
import { resetPassword } from './reset-password/orpc.gen'
import { resourceAccessTokens } from './resource-access-tokens/orpc.gen'
import { ruleCodeGenerate } from './rule-code-generate/orpc.gen'
import { ruleGenerate } from './rule-generate/orpc.gen'
import { ruleStructuredOutputGenerate } from './rule-structured-output-generate/orpc.gen'
@@ -92,6 +93,7 @@ const communityContract = {
refreshToken,
remoteFiles,
resetPassword,
resourceAccessTokens,
ruleCodeGenerate,
ruleGenerate,
ruleStructuredOutputGenerate,

View File

@@ -7,6 +7,7 @@ import {
describe('settingsQueryParser', () => {
it.each([
['roles-and-permissions', 'roles-and-permissions'],
['access-token', 'access-token'],
['preferences', 'preferences'],
['provider', 'provider'],
['mcp', 'mcp'],

View File

@@ -127,6 +127,11 @@ vi.mock('@/app/components/header/account-setting/access-rules-page', () => ({
default: () => <div data-testid="access-rules-page" />,
}))
vi.mock('@/app/components/header/account-setting/resource-access-token-page', () => ({
__esModule: true,
default: () => <div data-testid="resource-access-token-page" />,
}))
const baseConsoleState: ConsoleStateFixture = {
userProfile: {
id: '1',
@@ -234,6 +239,9 @@ describe('AccountSetting', () => {
expect(
screen.getByRole('button', { name: 'common.settings.permissionSet' }),
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.settings.accessToken' }),
).toBeInTheDocument()
expect(screen.getByText('common.settings.billing'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appLog.archives.title' })).toBeInTheDocument()
expect(screen.queryByText('common.settings.dataSource'))!.not.toBeInTheDocument()
@@ -411,6 +419,9 @@ describe('AccountSetting', () => {
expect(
screen.queryByRole('button', { name: 'common.settings.permissionSet' }),
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.settings.accessToken' }),
).toBeInTheDocument()
})
it('should hide role and permission set entries when RBAC is disabled', () => {
@@ -425,6 +436,26 @@ describe('AccountSetting', () => {
expect(
screen.queryByRole('button', { name: 'common.settings.permissionSet' }),
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.settings.accessToken' }),
).toBeInTheDocument()
})
it('should hide access tokens from non-owner workspaces', () => {
mockConsoleState.current = {
...baseConsoleState,
currentWorkspace: {
...baseConsoleState.currentWorkspace,
role: 'admin' as const,
},
isCurrentWorkspaceOwner: false,
}
renderAccountSetting()
expect(
screen.queryByRole('button', { name: 'common.settings.accessToken' }),
).not.toBeInTheDocument()
})
it('should not render direct role pages when RBAC is disabled', () => {
@@ -601,6 +632,10 @@ describe('AccountSetting', () => {
expect(screen.getByText('common.settings.permissionSetDescription')).toBeInTheDocument()
expect(screen.getByTestId('access-rules-page')).toBeInTheDocument()
// Access Tokens
fireEvent.click(screen.getByRole('button', { name: 'common.settings.accessToken' }))
expect(screen.getByTestId('resource-access-token-page')).toBeInTheDocument()
// Language
fireEvent.click(screen.getByText('common.settings.preferences'))
expect(screen.getByText('common.account.general')).toBeInTheDocument()

View File

@@ -2,6 +2,7 @@ export const ACCOUNT_SETTING_TAB = {
MEMBERS: 'members',
ROLES_AND_PERMISSIONS: 'roles-and-permissions',
PERMISSION_SET: 'permission-set',
ACCESS_TOKEN: 'access-token',
BILLING: 'billing',
WORKFLOW_LOG_ARCHIVES: 'workflow-log-archives',
CUSTOM: 'custom',
@@ -14,6 +15,7 @@ export const ACCOUNT_SETTING_TAB_VALUES = [
ACCOUNT_SETTING_TAB.MEMBERS,
ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS,
ACCOUNT_SETTING_TAB.PERMISSION_SET,
ACCOUNT_SETTING_TAB.ACCESS_TOKEN,
ACCOUNT_SETTING_TAB.BILLING,
ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
ACCOUNT_SETTING_TAB.CUSTOM,

View File

@@ -21,6 +21,7 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state'
import {
isCurrentWorkspaceDatasetOperatorAtom,
isCurrentWorkspaceManagerAtom,
isCurrentWorkspaceOwnerAtom,
} from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@@ -30,6 +31,7 @@ import AccessRulesPage from './access-rules-page'
import MembersPage from './members-page'
import PermissionsPage from './permissions-page'
import PreferencePage from './preference-page'
import ResourceAccessTokenPage from './resource-access-token-page'
import WorkflowLogArchivesPage from './workflow-log-archives-page'
const iconClassName = `
@@ -65,6 +67,7 @@ export default function AccountSetting({
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom)
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const canManageWorkspaceRoles =
@@ -84,6 +87,8 @@ export default function AccountSetting({
!canManageWorkspaceRoles
)
return ACCOUNT_SETTING_TAB.MEMBERS
if (activeTab === ACCOUNT_SETTING_TAB.ACCESS_TOKEN && !isCurrentWorkspaceOwner)
return ACCOUNT_SETTING_TAB.MEMBERS
return activeTab
})()
const scrollContainerRef = useRef<HTMLDivElement>(null)
@@ -108,6 +113,13 @@ export default function AccountSetting({
icon: <span className={cn('i-ri-lock-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-lock-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.ACCESS_TOKEN,
name: t(($) => $['settings.accessToken'], { ns: 'common' }),
description: t(($) => $['settings.accessTokenDescription'], { ns: 'common' }),
icon: <span className={cn('i-ri-key-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-key-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.BILLING,
name: t(($) => $['settings.billing'], { ns: 'common' }),
@@ -148,6 +160,8 @@ export default function AccountSetting({
visibleTabs.push(ACCOUNT_SETTING_TAB.PERMISSION_SET)
}
if (isCurrentWorkspaceOwner) visibleTabs.push(ACCOUNT_SETTING_TAB.ACCESS_TOKEN)
if (canViewBilling) visibleTabs.push(ACCOUNT_SETTING_TAB.BILLING)
if (enableReplaceWebAppLogo || systemFeatures.deployment_edition === 'CLOUD')
@@ -255,6 +269,7 @@ export default function AccountSetting({
<PermissionsPage containerRef={scrollContainerRef} />
)}
{activeMenu === ACCOUNT_SETTING_TAB.PERMISSION_SET && <AccessRulesPage />}
{activeMenu === ACCOUNT_SETTING_TAB.ACCESS_TOKEN && <ResourceAccessTokenPage />}
{activeMenu === ACCOUNT_SETTING_TAB.BILLING && <BillingPage />}
{activeMenu === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && (
<WorkflowLogArchivesPage />

View File

@@ -0,0 +1,414 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import ResourceAccessTokenPage from '../index'
const mocks = vi.hoisted(() => ({
createResourceAccessToken: vi.fn(),
deleteResourceAccessTokenRelation: vi.fn(),
listApps: vi.fn(),
listDatasets: vi.fn(),
listResourceAccessTokens: vi.fn(),
updateResourceAccessToken: vi.fn(),
}))
vi.mock('@/service/console', () => ({
consoleQuery: {
apps: {
get: {
queryOptions: (options?: unknown) => ({
enabled: (options as { enabled?: boolean } | undefined)?.enabled,
queryFn: mocks.listApps,
queryKey: ['apps'],
}),
},
},
datasets: {
get: {
queryOptions: (options?: unknown) => ({
enabled: (options as { enabled?: boolean } | undefined)?.enabled,
queryFn: mocks.listDatasets,
queryKey: ['datasets'],
}),
},
},
resourceAccessTokens: {
get: {
key: () => ['resource-access-tokens'],
queryOptions: (options?: unknown) => ({
queryFn: () => mocks.listResourceAccessTokens(options),
queryKey: ['resource-access-tokens', options],
}),
},
post: {
mutationOptions: () => ({
mutationFn: mocks.createResourceAccessToken,
}),
},
byTokenId: {
patch: {
mutationOptions: () => ({
mutationFn: mocks.updateResourceAccessToken,
}),
},
relations: {
byRelationId: {
delete: {
mutationOptions: () => ({
mutationFn: mocks.deleteResourceAccessTokenRelation,
}),
},
},
},
},
},
},
}))
vi.mock('@/app/notifications', () => ({
toast: {
success: vi.fn(),
},
}))
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
const renderWithQueryClient = (children: ReactNode) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>)
}
describe('ResourceAccessTokenPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listResourceAccessTokens.mockResolvedValue({
data: [
{
created_at: 1787000000,
last_used_at: null,
masked_token: 'sk-12345...abcd',
name: 'Production clients',
relation_id: 'relation-1',
resource_id: 'app-1',
resource_name: 'Support Bot',
resource_type: 'app',
token_id: 'token-1',
track_id: 'track-one',
},
{
created_at: 1787000000,
last_used_at: null,
masked_token: 'sk-12345...abcd',
name: 'Production clients',
relation_id: 'relation-2',
resource_id: 'dataset-1',
resource_name: 'Help Center',
resource_type: 'knowledge',
token_id: 'token-1',
track_id: 'track-one',
},
],
has_more: false,
limit: 20,
page: 1,
total: 1,
})
mocks.listApps.mockResolvedValue({
data: [{ id: 'app-2', name: 'Sales Bot' }],
has_more: false,
limit: 100,
page: 1,
total: 1,
})
mocks.listDatasets.mockResolvedValue({
data: [
{ enable_api: true, id: 'dataset-2', name: 'Sales Docs' },
{ enable_api: false, id: 'dataset-disabled', name: 'Hidden Docs' },
],
has_more: false,
limit: 100,
page: 1,
total: 2,
})
})
it('renders one row per token with an accessible resource summary', async () => {
renderWithQueryClient(<ResourceAccessTokenPage />)
expect(await screen.findByText('Production clients')).toBeInTheDocument()
expect(
screen.getByText(
'common.resourceAccessToken.appCount:{"count":1} · common.resourceAccessToken.knowledgeCount:{"count":1}',
),
).toBeInTheDocument()
expect(screen.getByText('track-on ...')).toBeInTheDocument()
expect(screen.getByText('sk-12345...abcd')).toBeInTheDocument()
expect(screen.queryByText('Support Bot')).not.toBeInTheDocument()
expect(screen.queryByText('Help Center')).not.toBeInTheDocument()
})
it('shows bound app and dataset names when hovering over the resource information', async () => {
const user = userEvent.setup()
renderWithQueryClient(<ResourceAccessTokenPage />)
await user.hover(
await screen.findByRole('button', {
name: 'Production clients · common.resourceAccessToken.accessibleResources',
}),
)
expect(await screen.findByText('Support Bot')).toBeInTheDocument()
expect(
within(
screen.getByRole('list', { name: 'common.resourceAccessToken.appsSection' }),
).getByText('Support Bot'),
).toBeInTheDocument()
expect(
within(
screen.getByRole('list', { name: 'common.resourceAccessToken.knowledgeBasesSection' }),
).getByText('Help Center'),
).toBeInTheDocument()
expect(screen.queryByText('Sales Bot')).not.toBeInTheDocument()
expect(mocks.listApps).not.toHaveBeenCalled()
expect(mocks.listDatasets).not.toHaveBeenCalled()
})
it('renders the empty state without the table when there are no access tokens', async () => {
mocks.listResourceAccessTokens.mockResolvedValue({
data: [],
has_more: false,
limit: 20,
page: 1,
total: 0,
})
renderWithQueryClient(<ResourceAccessTokenPage />)
expect(await screen.findByText('common.resourceAccessToken.empty')).toBeInTheDocument()
expect(screen.getByText('common.resourceAccessToken.emptyDescription')).toBeInTheDocument()
expect(screen.queryByText('common.resourceAccessToken.name')).not.toBeInTheDocument()
expect(
screen.queryByText('common.resourceAccessToken.total:{"total":0}'),
).not.toBeInTheDocument()
})
it('filters tokens by bound resource name', async () => {
const user = userEvent.setup()
mocks.listResourceAccessTokens.mockResolvedValue({
data: [
{
created_at: 1787000000,
last_used_at: null,
masked_token: 'sk-12345...abcd',
name: 'Production clients',
relation_id: 'relation-1',
resource_id: 'app-1',
resource_name: 'Support Bot',
resource_type: 'app',
token_id: 'token-1',
track_id: 'track-one',
},
{
created_at: 1787000000,
last_used_at: null,
masked_token: 'sk-56789...efgh',
name: 'CLI automation',
relation_id: 'relation-2',
resource_id: 'dataset-1',
resource_name: 'Help Center',
resource_type: 'knowledge',
token_id: 'token-2',
track_id: 'track-two',
},
],
has_more: false,
limit: 20,
page: 1,
total: 2,
})
renderWithQueryClient(<ResourceAccessTokenPage />)
await screen.findByText('Production clients')
await user.type(screen.getByRole('searchbox'), 'help')
expect(screen.queryByText('Production clients')).not.toBeInTheDocument()
expect(screen.getByText('CLI automation')).toBeInTheDocument()
})
it('creates one token for selected app and knowledge resources', async () => {
const user = userEvent.setup()
mocks.createResourceAccessToken.mockResolvedValue({
data: [],
token: 'sk-new-token',
})
renderWithQueryClient(<ResourceAccessTokenPage />)
await user.click(
await screen.findByRole('button', { name: 'common.resourceAccessToken.createButton' }),
)
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
await user.type(within(dialog).getByLabelText('common.resourceAccessToken.name'), 'Partners')
await user.click(await within(dialog).findByRole('checkbox', { name: 'Sales Bot' }))
await user.click(within(dialog).getByRole('checkbox', { name: 'Sales Docs' }))
expect(within(dialog).queryByRole('checkbox', { name: 'Hidden Docs' })).not.toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' }))
await waitFor(() => {
expect(mocks.createResourceAccessToken).toHaveBeenCalledWith(
{
body: {
name: 'Partners',
resources: [
{ id: 'app-2', type: 'app' },
{ id: 'dataset-2', type: 'knowledge' },
],
},
},
expect.anything(),
)
})
expect(await within(dialog).findByText('sk-new-token')).toBeInTheDocument()
expect(
within(dialog).getByText('common.resourceAccessToken.createdTokenDescription'),
).toBeInTheDocument()
expect(
within(dialog).getByRole('button', { name: 'common.resourceAccessToken.done' }),
).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'common.operation.copy' }))
await waitFor(() => {
expect(copy).toHaveBeenCalledWith('sk-new-token')
})
})
it('separates apps and knowledge bases in the create dialog and searches both types', async () => {
const user = userEvent.setup()
mocks.listApps.mockResolvedValue({
data: [
{ id: 'app-2', name: 'Sales Bot' },
{ id: 'app-3', name: 'Support Bot' },
],
has_more: false,
limit: 100,
page: 1,
total: 2,
})
mocks.listDatasets.mockResolvedValue({
data: [
{ enable_api: true, id: 'dataset-2', name: 'Sales Docs' },
{ enable_api: true, id: 'dataset-3', name: 'Support Docs' },
],
has_more: false,
limit: 100,
page: 1,
total: 2,
})
renderWithQueryClient(<ResourceAccessTokenPage />)
await user.click(
await screen.findByRole('button', { name: 'common.resourceAccessToken.createButton' }),
)
const dialog = screen.getByRole('dialog')
expect(
await within(dialog).findByText('common.resourceAccessToken.appsSection'),
).toBeInTheDocument()
expect(
within(dialog).getByText('common.resourceAccessToken.knowledgeBasesSection'),
).toBeInTheDocument()
await user.type(within(dialog).getByRole('searchbox'), 'sales')
expect(within(dialog).getByRole('checkbox', { name: 'Sales Bot' })).toBeInTheDocument()
expect(within(dialog).getByRole('checkbox', { name: 'Sales Docs' })).toBeInTheDocument()
expect(within(dialog).queryByRole('checkbox', { name: 'Support Bot' })).not.toBeInTheDocument()
expect(within(dialog).queryByRole('checkbox', { name: 'Support Docs' })).not.toBeInTheDocument()
await user.click(
within(dialog).getByRole('tab', { name: 'common.resourceAccessToken.tabApps' }),
)
expect(within(dialog).getByRole('checkbox', { name: 'Sales Bot' })).toBeInTheDocument()
expect(within(dialog).queryByRole('checkbox', { name: 'Sales Docs' })).not.toBeInTheDocument()
await user.click(
within(dialog).getByRole('tab', { name: 'common.resourceAccessToken.tabKnowledgeBases' }),
)
expect(within(dialog).queryByRole('checkbox', { name: 'Sales Bot' })).not.toBeInTheDocument()
expect(within(dialog).getByRole('checkbox', { name: 'Sales Docs' })).toBeInTheDocument()
})
it('edits the token name and selected resources for the selected row', async () => {
const user = userEvent.setup()
mocks.updateResourceAccessToken.mockResolvedValue({
data: [],
has_more: false,
limit: 1,
page: 1,
total: 1,
})
renderWithQueryClient(<ResourceAccessTokenPage />)
await screen.findByText('Production clients')
await user.click(screen.getAllByRole('button', { name: 'common.operation.edit' })[0]!)
const dialog = screen.getByRole('dialog')
expect(await within(dialog).findByRole('checkbox', { name: 'Support Bot' })).toBeChecked()
expect(within(dialog).getByRole('checkbox', { name: 'Help Center' })).toBeChecked()
expect(
within(dialog).getByText('common.dynamicSelect.selected:{"count":2}'),
).toBeInTheDocument()
await user.click(within(dialog).getByRole('checkbox', { name: 'Help Center' }))
await user.click(within(dialog).getByRole('checkbox', { name: 'Sales Bot' }))
await user.clear(within(dialog).getByLabelText('common.resourceAccessToken.name'))
await user.type(within(dialog).getByLabelText('common.resourceAccessToken.name'), 'Renamed')
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mocks.updateResourceAccessToken).toHaveBeenCalledWith(
{
body: {
name: 'Renamed',
resources: [
{ id: 'app-2', type: 'app' },
{ id: 'app-1', type: 'app' },
],
},
params: { token_id: 'token-1' },
},
expect.anything(),
)
})
})
it('deletes the selected token', async () => {
const user = userEvent.setup()
mocks.deleteResourceAccessTokenRelation.mockResolvedValue(undefined)
renderWithQueryClient(<ResourceAccessTokenPage />)
await screen.findByText('Production clients')
await user.click(screen.getAllByRole('button', { name: 'common.operation.delete' })[0]!)
const dialog = screen.getByRole('dialog')
await user.click(within(dialog).getByRole('button', { name: 'common.operation.delete' }))
await waitFor(() => {
expect(mocks.deleteResourceAccessTokenRelation).toHaveBeenCalledWith(
{
params: {
relation_id: 'relation-1',
token_id: 'token-1',
},
},
expect.anything(),
)
})
})
})

View File

@@ -0,0 +1,808 @@
'use client'
import type {
ResourceAccessTokenResourcePayload,
ResourceAccessTokenRowResponse,
} from '@dify/contracts/api/console/resource-access-tokens/types.gen'
import type { FormEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Input } from '@langgenius/dify-ui/input'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import copy from 'copy-to-clipboard'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { SearchInput } from '@/app/components/base/search-input'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { toast } from '@/app/notifications'
import { consoleQuery } from '@/service/console'
const PAGE_SIZE = 20
type DialogState =
| {
mode: 'create'
}
| {
mode: 'edit'
row: TokenTableRow
}
| null
type ResourceCandidate = ResourceAccessTokenResourcePayload & {
name: string
}
type ResourceTab = 'all' | 'app' | 'knowledge'
type TokenTableRow = {
tokenId: string
name: string
trackId: string
maskedToken: string
createdAt?: number | null
relations: ResourceAccessTokenRowResponse[]
}
const candidateKey = (candidate: ResourceAccessTokenResourcePayload) =>
`${candidate.type}:${candidate.id}`
const rowResourceKey = (row: ResourceAccessTokenRowResponse) =>
candidateKey({
id: row.resource_id,
type: row.resource_type,
})
const formatTime = (timestamp?: number | null) => {
if (!timestamp) return '-'
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
const formatTrackingId = (trackId: string) => {
if (trackId.length <= 8) return trackId
return `${trackId.slice(0, 8)} ...`
}
const getTokenRows = (rows: ResourceAccessTokenRowResponse[]) => {
const rowMap = new Map<string, TokenTableRow>()
rows.forEach((row) => {
const tokenRow = rowMap.get(row.token_id)
if (tokenRow) {
tokenRow.relations.push(row)
return
}
rowMap.set(row.token_id, {
tokenId: row.token_id,
name: row.name,
trackId: row.track_id,
maskedToken: row.masked_token,
createdAt: row.created_at,
relations: [row],
})
})
return Array.from(rowMap.values())
}
const getResourceSummary = (
relations: ResourceAccessTokenRowResponse[],
t: ReturnType<typeof useTranslation>['t'],
) => {
const appCount = relations.filter((relation) => relation.resource_type === 'app').length
const knowledgeCount = relations.filter(
(relation) => relation.resource_type === 'knowledge',
).length
const parts: string[] = []
if (appCount)
parts.push(
t(
($) => $[appCount === 1 ? 'resourceAccessToken.appCount' : 'resourceAccessToken.appsCount'],
{ ns: 'common', count: appCount },
),
)
if (knowledgeCount)
parts.push(
t(
($) =>
$[
knowledgeCount === 1
? 'resourceAccessToken.knowledgeCount'
: 'resourceAccessToken.knowledgesCount'
],
{ ns: 'common', count: knowledgeCount },
),
)
return parts.join(' · ')
}
function useInvalidateResourceAccessTokens() {
const queryClient = useQueryClient()
return () =>
queryClient.invalidateQueries({
queryKey: consoleQuery.resourceAccessTokens.get.key(),
})
}
function ResourceAccessTokenDialog({
open,
state,
onOpenChange,
onSaved,
}: {
open: boolean
state: DialogState
onOpenChange: (open: boolean) => void
onSaved: () => void
}) {
const { t } = useTranslation()
const isCreate = state?.mode === 'create'
const isEdit = state?.mode === 'edit'
const [name, setName] = useState(isEdit ? state.row.name : '')
const [selectedResources, setSelectedResources] = useState<Set<string>>(
() => new Set(isEdit ? state.row.relations.map(rowResourceKey) : []),
)
const [resourceTab, setResourceTab] = useState<ResourceTab>('all')
const [resourceSearchText, setResourceSearchText] = useState('')
const [createdToken, setCreatedToken] = useState<string | null>(null)
const invalidateResourceAccessTokens = useInvalidateResourceAccessTokens()
const createMutation = useMutation(consoleQuery.resourceAccessTokens.post.mutationOptions())
const updateMutation = useMutation(
consoleQuery.resourceAccessTokens.byTokenId.patch.mutationOptions(),
)
const appsQuery = useQuery(
consoleQuery.apps.get.queryOptions({
enabled: isCreate || isEdit,
input: {
query: {
limit: 100,
page: 1,
},
},
}),
)
const datasetsQuery = useQuery(
consoleQuery.datasets.get.queryOptions({
enabled: isCreate || isEdit,
input: {
query: {
include_all: true,
limit: 100,
page: 1,
},
},
}),
)
const selectedRelationCandidates = useMemo<ResourceCandidate[]>(
() =>
isEdit
? state.row.relations.map((relation) => ({
id: relation.resource_id,
name: relation.resource_name,
type: relation.resource_type,
}))
: [],
[isEdit, state],
)
const appCandidates = useMemo<ResourceCandidate[]>(() => {
const candidates: ResourceCandidate[] =
appsQuery.data?.data.map((app) => ({
id: app.id,
name: app.name,
type: 'app' as const,
})) ?? []
selectedRelationCandidates
.filter((candidate) => candidate.type === 'app')
.forEach((candidate) => {
if (!candidates.some((item) => item.id === candidate.id)) candidates.push(candidate)
})
return candidates
}, [appsQuery.data?.data, selectedRelationCandidates])
const knowledgeCandidates = useMemo<ResourceCandidate[]>(() => {
const candidates: ResourceCandidate[] =
datasetsQuery.data?.data
.filter((dataset) => dataset.enable_api)
.map((dataset) => ({
id: dataset.id,
name: dataset.name,
type: 'knowledge' as const,
})) ?? []
selectedRelationCandidates
.filter((candidate) => candidate.type === 'knowledge')
.forEach((candidate) => {
if (!candidates.some((item) => item.id === candidate.id)) candidates.push(candidate)
})
return candidates
}, [datasetsQuery.data?.data, selectedRelationCandidates])
const resourceCandidates = useMemo(
() => [...appCandidates, ...knowledgeCandidates],
[appCandidates, knowledgeCandidates],
)
const filteredAppCandidates = useMemo(() => {
const keyword = resourceSearchText.trim().toLowerCase()
if (!keyword) return appCandidates
return appCandidates.filter((candidate) => candidate.name.toLowerCase().includes(keyword))
}, [appCandidates, resourceSearchText])
const filteredKnowledgeCandidates = useMemo(() => {
const keyword = resourceSearchText.trim().toLowerCase()
if (!keyword) return knowledgeCandidates
return knowledgeCandidates.filter((candidate) => candidate.name.toLowerCase().includes(keyword))
}, [knowledgeCandidates, resourceSearchText])
const isSaving = createMutation.isPending || updateMutation.isPending
const nameLabel = t(($) => $['resourceAccessToken.name'], { ns: 'common' })
const toggleResource = (candidate: ResourceCandidate) => {
const key = candidateKey(candidate)
setSelectedResources((current) => {
const next = new Set(current)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
const renderCandidate = (candidate: ResourceCandidate) => {
const key = candidateKey(candidate)
return (
<div
key={key}
className="flex min-h-10 w-full items-center gap-3 border-b border-divider-subtle px-3 text-left last:border-b-0 hover:bg-state-base-hover"
>
<Checkbox
checked={selectedResources.has(key)}
aria-label={candidate.name}
onCheckedChange={() => toggleResource(candidate)}
/>
<span
aria-hidden
className={
candidate.type === 'app'
? 'i-ri-apps-2-line size-4 text-text-tertiary'
: 'i-ri-book-2-line size-4 text-text-tertiary'
}
/>
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-primary">
{candidate.name}
</span>
<span className="system-xs-medium-uppercase text-text-tertiary">
{candidate.type === 'app'
? t(($) => $['resourceAccessToken.app'], { ns: 'common' })
: t(($) => $['resourceAccessToken.knowledge'], { ns: 'common' })}
</span>
</div>
)
}
const renderCandidateSection = (title: string, candidates: ResourceCandidate[]) => {
if (!candidates.length) return null
return (
<div>
<div className="flex h-8 items-center border-b border-divider-subtle px-3 system-xs-medium-uppercase text-text-tertiary">
{title}
</div>
{candidates.map(renderCandidate)}
</div>
)
}
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const normalizedName = name.trim()
if (!normalizedName) return
const resources = resourceCandidates.filter((candidate) =>
selectedResources.has(candidateKey(candidate)),
)
if (!resources.length) return
if (isEdit) {
updateMutation.mutate(
{
body: {
name: normalizedName,
resources: resources.map(({ id, type }) => ({ id, type })),
},
params: {
token_id: state.row.tokenId,
},
},
{
onSuccess: () => {
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
void invalidateResourceAccessTokens()
onSaved()
},
},
)
return
}
createMutation.mutate(
{
body: {
name: normalizedName,
resources: resources.map(({ id, type }) => ({ id, type })),
},
},
{
onSuccess: (result) => {
toast.success(t(($) => $['resourceAccessToken.created'], { ns: 'common' }))
void invalidateResourceAccessTokens()
setCreatedToken(result.token)
},
},
)
}
const copyCreatedToken = () => {
if (!createdToken) return
copy(createdToken)
toast.success(t(($) => $['resourceAccessToken.copied'], { ns: 'common' }))
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-160 border-none p-8 pb-6 text-left"
>
<DialogClose
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-6 top-6"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
<DialogTitle className="mb-5 pr-8 text-xl font-semibold text-text-primary">
{isEdit
? t(($) => $['resourceAccessToken.editTitle'], { ns: 'common' })
: createdToken
? t(($) => $['resourceAccessToken.createdToken'], { ns: 'common' })
: t(($) => $['resourceAccessToken.createTitle'], { ns: 'common' })}
</DialogTitle>
{createdToken ? (
<div className="grid gap-5">
<div className="grid gap-2">
<div className="system-sm-regular text-text-tertiary">
{t(($) => $['resourceAccessToken.createdTokenDescription'], { ns: 'common' })}
</div>
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-components-panel-border bg-components-input-bg-normal p-2">
<code className="min-w-0 flex-1 truncate px-1 font-mono text-sm text-text-primary">
{createdToken}
</code>
<IconButton
size="md"
aria-label={t(($) => $['operation.copy'], { ns: 'common' })}
onClick={copyCreatedToken}
>
<span aria-hidden className="i-ri-file-copy-line size-4" />
</IconButton>
</div>
</div>
<div className="flex justify-end">
<Button onClick={() => onOpenChange(false)}>
{t(($) => $['resourceAccessToken.done'], { ns: 'common' })}
</Button>
</div>
</div>
) : (
<form className="grid gap-5" onSubmit={handleSubmit}>
<Field name="name">
<FieldLabel>{nameLabel}</FieldLabel>
<Input
required
value={name}
placeholder={t(($) => $['resourceAccessToken.namePlaceholder'], { ns: 'common' })}
onChange={(event) => setName(event.target.value)}
/>
<FieldError match="valueMissing">
{t(($) => $['errorMsg.fieldRequired'], { ns: 'common', field: nameLabel })}
</FieldError>
</Field>
{(isCreate || isEdit) && (
<div className="grid gap-2">
<div className="system-sm-medium text-text-secondary">
{t(($) => $['resourceAccessToken.resources'], { ns: 'common' })}
</div>
<div className="grid gap-3">
<SearchInput
value={resourceSearchText}
onValueChange={setResourceSearchText}
placeholder={t(($) => $['resourceAccessToken.searchResourcesPlaceholder'], {
ns: 'common',
})}
/>
<Tabs
value={resourceTab}
onValueChange={(value) => setResourceTab(value as ResourceTab)}
>
<div className="flex items-center justify-between gap-3">
<TabsList className="gap-5">
<TabsTab value="all" className="py-1.5 system-sm-semibold">
{t(($) => $['resourceAccessToken.tabAll'], { ns: 'common' })}
</TabsTab>
<TabsTab value="app" className="py-1.5 system-sm-semibold">
{t(($) => $['resourceAccessToken.tabApps'], { ns: 'common' })}
</TabsTab>
<TabsTab value="knowledge" className="py-1.5 system-sm-semibold">
{t(($) => $['resourceAccessToken.tabKnowledgeBases'], { ns: 'common' })}
</TabsTab>
</TabsList>
<div className="rounded-full bg-state-accent-hover px-3 py-1 system-sm-semibold text-text-accent">
{t(($) => $['dynamicSelect.selected'], {
ns: 'common',
count: selectedResources.size,
})}
</div>
</div>
<div className="max-h-72 overflow-y-auto rounded-lg border border-components-panel-border">
{(appsQuery.isPending || datasetsQuery.isPending) && (
<div className="grid gap-2 p-3">
<SkeletonRectangle className="h-8 w-full animate-pulse" />
<SkeletonRectangle className="h-8 w-full animate-pulse" />
<SkeletonRectangle className="h-8 w-full animate-pulse" />
</div>
)}
{!appsQuery.isPending && !datasetsQuery.isPending && (
<>
<TabsPanel value="all">
{renderCandidateSection(
t(($) => $['resourceAccessToken.appsSection'], { ns: 'common' }),
filteredAppCandidates,
)}
{renderCandidateSection(
t(($) => $['resourceAccessToken.knowledgeBasesSection'], {
ns: 'common',
}),
filteredKnowledgeCandidates,
)}
</TabsPanel>
<TabsPanel value="app">
{filteredAppCandidates.map(renderCandidate)}
</TabsPanel>
<TabsPanel value="knowledge">
{filteredKnowledgeCandidates.map(renderCandidate)}
</TabsPanel>
</>
)}
{!appsQuery.isPending &&
!datasetsQuery.isPending &&
((resourceTab === 'all' &&
filteredAppCandidates.length === 0 &&
filteredKnowledgeCandidates.length === 0) ||
(resourceTab === 'app' && filteredAppCandidates.length === 0) ||
(resourceTab === 'knowledge' &&
filteredKnowledgeCandidates.length === 0)) && (
<div className="p-6 text-center system-sm-regular text-text-tertiary">
{resourceCandidates.length === 0
? t(($) => $['resourceAccessToken.noResources'], { ns: 'common' })
: t(($) => $['resourceAccessToken.noResourceSearchResults'], {
ns: 'common',
})}
</div>
)}
</div>
</Tabs>
</div>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button type="button" onClick={() => onOpenChange(false)}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button
type="submit"
variant="primary"
disabled={
isSaving ||
!name.trim() ||
selectedResources.size === 0 ||
resourceCandidates.length === 0
}
>
{isEdit
? t(($) => $['operation.save'], { ns: 'common' })
: t(($) => $['operation.create'], { ns: 'common' })}
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
)
}
function ResourceAccessTokenRow({
row,
onEdit,
onDelete,
}: {
row: TokenTableRow
onEdit: (row: TokenTableRow) => void
onDelete: (row: ResourceAccessTokenRowResponse) => void
}) {
const { t } = useTranslation()
const editRow = row.relations[0]
const resourceSummary = getResourceSummary(row.relations, t)
if (!editRow) return null
return (
<div className="grid min-h-12 grid-cols-[minmax(140px,1.15fr)_minmax(140px,1fr)_minmax(140px,1fr)_minmax(180px,1.2fr)_minmax(120px,0.8fr)_80px] items-center gap-6 border-b border-divider-subtle text-sm last:border-b-0">
<div className="min-w-0 truncate font-medium text-text-primary">{row.name}</div>
<div className="min-w-0 truncate font-mono text-[13px] text-text-secondary">
{formatTrackingId(row.trackId)}
</div>
<div className="min-w-0 truncate font-mono text-[13px] text-text-primary">
{row.maskedToken}
</div>
<div className="flex min-w-0 items-center gap-1">
<div className="min-w-0 truncate text-text-secondary">
{resourceSummary ||
t(($) => $['resourceAccessToken.noAccessibleResources'], { ns: 'common' })}
</div>
<Infotip
iconVariant="information"
aria-label={`${row.name} · ${t(($) => $['resourceAccessToken.accessibleResources'], { ns: 'common' })}`}
>
<div className="grid max-h-60 min-w-48 gap-3 overflow-y-auto">
{(['app', 'knowledge'] as const).map((type) => {
const resources = row.relations.filter((relation) => relation.resource_type === type)
if (resources.length === 0) return null
const label = t(
($) =>
$[
type === 'app'
? 'resourceAccessToken.appsSection'
: 'resourceAccessToken.knowledgeBasesSection'
],
{ ns: 'common' },
)
return (
<div key={type}>
<div className="mb-1 system-xs-semibold text-text-secondary">{label}</div>
<ul aria-label={label} className="grid gap-1">
{resources.map((resource) => (
<li key={resource.relation_id} className="wrap-break-word">
{resource.resource_name || resource.resource_id}
</li>
))}
</ul>
</div>
)
})}
</div>
</Infotip>
</div>
<div className="min-w-0 truncate text-text-secondary">{formatTime(row.createdAt)}</div>
<div className="flex items-center gap-1 text-text-tertiary">
<IconButton
size="sm"
variant="tertiary"
aria-label={t(($) => $['operation.edit'], { ns: 'common' })}
onClick={() => onEdit(row)}
>
<span aria-hidden className="i-ri-edit-line size-4" />
</IconButton>
<IconButton
size="sm"
variant="tertiary"
aria-label={t(($) => $['operation.delete'], { ns: 'common' })}
onClick={() => onDelete(editRow)}
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
</IconButton>
</div>
</div>
)
}
export default function ResourceAccessTokenPage() {
const { t } = useTranslation()
const [page, setPage] = useState(1)
const [searchText, setSearchText] = useState('')
const [dialogState, setDialogState] = useState<DialogState>(null)
const [deletingRow, setDeletingRow] = useState<ResourceAccessTokenRowResponse | null>(null)
const invalidateResourceAccessTokens = useInvalidateResourceAccessTokens()
const deleteMutation = useMutation(
consoleQuery.resourceAccessTokens.byTokenId.relations.byRelationId.delete.mutationOptions(),
)
const tokensQuery = useQuery(
consoleQuery.resourceAccessTokens.get.queryOptions({
input: {
query: {
limit: PAGE_SIZE,
page,
},
},
}),
)
const rows = tokensQuery.data?.data
const total = tokensQuery.data?.total ?? 0
const tokenRows = useMemo(() => getTokenRows(rows ?? []), [rows])
const isEmptyList = !tokensQuery.isPending && total === 0 && !searchText.trim()
const filteredRows = useMemo(() => {
const keyword = searchText.trim().toLowerCase()
if (!keyword) return tokenRows
return tokenRows.filter((row) => {
const searchableText = [
row.name,
row.trackId,
row.maskedToken,
...row.relations.map((relation) => relation.resource_name),
]
.join(' ')
.toLowerCase()
return searchableText.includes(keyword)
})
}, [searchText, tokenRows])
const confirmDelete = () => {
if (!deletingRow) return
deleteMutation.mutate(
{
params: {
relation_id: deletingRow.relation_id,
token_id: deletingRow.token_id,
},
},
{
onSuccess: () => {
toast.success(t(($) => $['resourceAccessToken.deleted'], { ns: 'common' }))
setDeletingRow(null)
void invalidateResourceAccessTokens()
},
},
)
}
return (
<div className="grid gap-6">
<div className="flex items-center justify-between gap-3">
<SearchInput
className="w-80"
placeholder={t(($) => $['resourceAccessToken.searchPlaceholder'], { ns: 'common' })}
value={searchText}
onValueChange={setSearchText}
/>
<Button variant="primary" onClick={() => setDialogState({ mode: 'create' })}>
<span aria-hidden className="i-ri-add-line size-4" />
{t(($) => $['resourceAccessToken.createButton'], { ns: 'common' })}
</Button>
</div>
{isEmptyList ? (
<div className="flex min-h-[360px] items-center justify-center text-center">
<div className="grid max-w-[520px] gap-3">
<div className="title-md-semi-bold text-text-primary">
{t(($) => $['resourceAccessToken.empty'], { ns: 'common' })}
</div>
<div className="system-md-regular text-text-tertiary">
{t(($) => $['resourceAccessToken.emptyDescription'], { ns: 'common' })}
</div>
</div>
</div>
) : (
<div className="overflow-x-auto">
<div className="grid min-w-[900px] grid-cols-[minmax(140px,1.15fr)_minmax(140px,1fr)_minmax(140px,1fr)_minmax(180px,1.2fr)_minmax(120px,0.8fr)_80px] gap-6 border-b border-divider-subtle pb-3 system-xs-medium-uppercase text-text-tertiary">
<div>{t(($) => $['resourceAccessToken.name'], { ns: 'common' })}</div>
<div>{t(($) => $['resourceAccessToken.trackingId'], { ns: 'common' })}</div>
<div>{t(($) => $['resourceAccessToken.token'], { ns: 'common' })}</div>
<div>{t(($) => $['resourceAccessToken.accessibleResources'], { ns: 'common' })}</div>
<div>{t(($) => $['resourceAccessToken.createdColumn'], { ns: 'common' })}</div>
<div>{t(($) => $['resourceAccessToken.actions'], { ns: 'common' })}</div>
</div>
{tokensQuery.isPending && (
<div className="grid gap-2 py-4">
<SkeletonRectangle className="h-12 w-full animate-pulse" />
<SkeletonRectangle className="h-12 w-full animate-pulse" />
</div>
)}
{!tokensQuery.isPending &&
filteredRows.map((row) => (
<ResourceAccessTokenRow
key={row.tokenId}
row={row}
onEdit={(row) => setDialogState({ mode: 'edit', row })}
onDelete={setDeletingRow}
/>
))}
{!tokensQuery.isPending && filteredRows.length === 0 && (
<div className="p-10 text-center system-sm-regular text-text-tertiary">
{t(($) => $['resourceAccessToken.empty'], { ns: 'common' })}
</div>
)}
</div>
)}
{!isEmptyList && (
<div className="flex items-center justify-between system-xs-regular text-text-tertiary">
<span>{t(($) => $['resourceAccessToken.total'], { ns: 'common', total })}</span>
<div className="flex items-center gap-2">
<Button size="small" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>
{t(($) => $['resourceAccessToken.previous'], { ns: 'common' })}
</Button>
<span>{page}</span>
<Button
size="small"
disabled={!tokensQuery.data?.has_more}
onClick={() => setPage((value) => value + 1)}
>
{t(($) => $['resourceAccessToken.next'], { ns: 'common' })}
</Button>
</div>
</div>
)}
{dialogState && (
<ResourceAccessTokenDialog
open
state={dialogState}
onOpenChange={(open) => !open && setDialogState(null)}
onSaved={() => {
setDialogState(null)
}}
/>
)}
<Dialog open={!!deletingRow} onOpenChange={(open) => !open && setDeletingRow(null)}>
<DialogContent className="w-120 p-6 text-left" backdropProps={{ forceRender: true }}>
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['resourceAccessToken.deleteTitle'], { ns: 'common' })}
</DialogTitle>
<div className="mt-2 system-sm-regular text-text-tertiary">
{t(($) => $['resourceAccessToken.deleteDescription'], { ns: 'common' })}
</div>
<div className="mt-6 flex justify-end gap-2">
<Button onClick={() => setDeletingRow(null)}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button
variant="primary"
tone="destructive"
disabled={deleteMutation.isPending}
onClick={confirmDelete}
>
{t(($) => $['operation.delete'], { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -112,7 +112,7 @@ describe('HydrateQueryClient', () => {
expect(state.queries[0]?.queryKey).toEqual([
'marketplace',
'collections',
{ input: { query: {} } },
{ input: { query: { limit: 20 } } },
])
})

View File

@@ -311,12 +311,13 @@ describe('getMarketplacePluginsByCollectionId', () => {
)
})
it('should omit a preview limit when query is omitted', async () => {
it('should send the warmed preview limit when query is omitted', async () => {
mockCollectionPlugins.mockResolvedValueOnce({
data: { plugins: [] },
})
const { getMarketplacePluginsByCollectionId } = await import('../utils')
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplacePluginsByCollectionId } =
await import('../utils')
await getMarketplacePluginsByCollectionId('test-collection')
expect(mockCollectionPlugins).toHaveBeenCalledWith(
@@ -324,7 +325,7 @@ describe('getMarketplacePluginsByCollectionId', () => {
params: {
collectionId: 'test-collection',
},
body: {},
body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT },
},
expect.objectContaining({
signal: undefined,
@@ -362,7 +363,8 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
mockCollections.mockResolvedValueOnce({ data: { collections: mockCollectionData } })
mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } })
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplaceCollectionsAndPlugins } =
await import('../utils')
const result = await getMarketplaceCollectionsAndPlugins({
condition: 'category=tool',
type: 'plugin',
@@ -376,13 +378,14 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
body: {
condition: 'category=tool',
type: 'plugin',
limit: COLLECTION_PREVIEW_PLUGIN_LIMIT,
},
}),
expect.any(Object),
)
})
it('posts without a preview limit when the catalog has no extra filters', async () => {
it('posts the warmed preview limit when the catalog has no extra filters', async () => {
mockCollections.mockResolvedValueOnce({
data: {
collections: [
@@ -399,13 +402,14 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
})
mockCollectionPlugins.mockResolvedValue({ data: { plugins: [] } })
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplaceCollectionsAndPlugins } =
await import('../utils')
await getMarketplaceCollectionsAndPlugins()
expect(mockCollectionPlugins).toHaveBeenCalledWith(
expect.objectContaining({
params: { collectionId: 'featured' },
body: {},
body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT },
}),
expect.any(Object),
)
@@ -486,18 +490,22 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
})
describe('getCollectionsParams', () => {
it('should return an empty query for all category', async () => {
const { getCollectionsParams } = await import('../utils')
expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({})
it('should return the warmed preview limit for all category', async () => {
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getCollectionsParams } = await import('../utils')
expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({
limit: COLLECTION_PREVIEW_PLUGIN_LIMIT,
})
expect(COLLECTION_PREVIEW_PLUGIN_LIMIT).toBe(20)
})
it('should return category, condition, and type for tool category', async () => {
const { getCollectionsParams } = await import('../utils')
it('should return category, condition, type, and preview limit for tool category', async () => {
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getCollectionsParams } = await import('../utils')
const result = getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.tool)
expect(result).toEqual({
category: PluginCategoryEnum.tool,
condition: 'category=tool',
type: 'plugin',
limit: COLLECTION_PREVIEW_PLUGIN_LIMIT,
})
})
})

View File

@@ -0,0 +1,41 @@
import { render } from 'vitest-browser-react'
import HomeCatalogNavigation from '../home-catalog-navigation'
import HomeCatalogTabs from '../home-catalog-tabs'
import { HomeStickyStateProvider } from '../home-sticky-state-provider'
import styles from '../home-sticky.module.css'
describe('Marketplace home catalog alignment', () => {
it('aligns catalog tabs and filters with the content container', async () => {
const screen = await render(
<HomeStickyStateProvider>
<div className="w-[1200px]" data-marketplace-standalone>
<HomeCatalogNavigation
isMarketplacePlatform
catalogCategories={
<div data-testid="catalog-filter" role="group" aria-label="Categories" />
}
catalogTabs={
<HomeCatalogTabs
isMarketplacePlatform
labels={{ plugins: 'Plugins', templates: 'Templates' }}
/>
}
/>
<div className={`px-8 ${styles.catalogContent}`}>
<div role="region" aria-label="Catalog content" className="h-10" />
</div>
</div>
</HomeStickyStateProvider>,
)
const contentLeft = screen
.getByRole('region', { name: 'Catalog content' })
.element()
.getBoundingClientRect().left
const tabsLeft = screen.getByRole('navigation').element().getBoundingClientRect().left
const filtersLeft = screen.getByTestId('catalog-filter').element().getBoundingClientRect().left
expect(tabsLeft).toBeCloseTo(contentLeft)
expect(filtersLeft).toBeCloseTo(contentLeft)
})
})

View File

@@ -16,6 +16,10 @@ type MarketplaceFetchOptions = {
signal?: AbortSignal
}
// Matches backend warmup homepageCollectionPluginsRequests Limit: 20 so the
// public POST hits the Redis bucket the scheduler already writes.
export const COLLECTION_PREVIEW_PLUGIN_LIMIT = 20
type MarketplacePluginListExtras = {
agent_strategy?: unknown
data_sources?: unknown
@@ -150,7 +154,7 @@ export const getMarketplacePluginsByCollectionId = async (
params: {
collectionId,
},
body: query ?? {},
body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, ...query },
},
{
signal: options?.signal,
@@ -291,11 +295,12 @@ export function getCollectionsParams(
category: ActivePluginType,
): CollectionsAndPluginsSearchParams {
if (category === PLUGIN_TYPE_SEARCH_MAP.all) {
return {}
return { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT }
}
return {
category,
condition: getMarketplaceListCondition(category),
type: getMarketplaceListFilterType(category),
limit: COLLECTION_PREVIEW_PLUGIN_LIMIT,
}
}

View File

@@ -287,6 +287,7 @@ describe('AllStartBlocks', () => {
createMarketplacePluginsMock([
createPlugin({
name: 'start-marketplace',
plugin_id: 'plugin-2',
label: { en_US: 'Start Marketplace', zh_Hans: 'Start Marketplace' },
}),
]),
@@ -311,6 +312,28 @@ describe('AllStartBlocks', () => {
expect(screen.getAllByRole('link', { name: /plugin\.searchInMarketplace/i })).toHaveLength(1)
})
it('shows installed triggers once and keeps other marketplace results available', async () => {
enableMarketplaceForRender = true
mockUseMarketplacePlugins.mockReturnValue(
createMarketplacePluginsMock([
createPlugin({ plugin_id: 'plugin-1', label: { en_US: 'Installed Marketplace' } }),
createPlugin({ plugin_id: 'plugin-2', label: { en_US: 'Available Marketplace' } }),
]),
)
render(
<AllStartBlocks
searchText="provider"
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.TriggerPlugin]}
/>,
)
expect(await screen.findByText('Provider One')).toBeInTheDocument()
expect(screen.queryByText('Installed Marketplace')).not.toBeInTheDocument()
expect(screen.getByText('Available Marketplace')).toBeInTheDocument()
})
it('should show the user input conflict state without allowing another start selection', () => {
const onSelect = vi.fn()
enableMarketplaceForRender = true

View File

@@ -10,6 +10,7 @@ import { renderWithConsoleQuery } from '@/test/console/query-data'
import { Theme } from '@/types/app'
import { BlockEnum } from '../../types'
import DataSources from '../data-sources'
import { createPlugin } from './factories'
vi.mock('@/context/i18n', () => ({
useGetLanguage: vi.fn(),
@@ -169,5 +170,40 @@ describe('DataSources', () => {
})
})
})
it('shows an installed source once and keeps other marketplace plugins available', async () => {
enableMarketplaceForRender = true
const provider = createToolProvider()
mockUseMarketplacePlugins.mockReturnValue(
createMarketplacePluginsMock({
data: {
pages: [
{
plugins: [
createPlugin({
plugin_id: provider.plugin_id,
label: { en_US: 'Installed Marketplace Source' },
}),
createPlugin({
plugin_id: 'langgenius/other',
label: { en_US: 'Other Marketplace Source' },
}),
],
page: 1,
page_size: 40,
total: 2,
},
],
pageParams: [1],
},
}),
)
render(<DataSources searchText="file" onSelect={vi.fn()} dataSources={[provider]} />)
expect(screen.getByText('File Source')).toBeInTheDocument()
expect(screen.queryByText('Installed Marketplace Source')).not.toBeInTheDocument()
expect(screen.getByText('Other Marketplace Source')).toBeInTheDocument()
})
})
})

View File

@@ -117,9 +117,12 @@ function AllStartBlocks({
)
const { data: marketplacePluginsData, isFetching: isMarketplaceFetching } =
useMarketplacePlugins(marketplaceSearchParams)
const marketplacePlugins = useMemo(
() => marketplacePluginsData?.pages.flatMap((page) => page.plugins) ?? [],
[marketplacePluginsData?.pages],
const notInstalledPlugins = useMemo(
() =>
marketplacePluginsData?.pages.flatMap((page) =>
page.plugins.filter((plugin) => !providerMap.has(plugin.plugin_id)),
) ?? [],
[marketplacePluginsData?.pages, providerMap],
)
const shouldShowFeatured = enableTriggerPlugin && enable_marketplace && !hasFilter
@@ -136,7 +139,7 @@ function AllStartBlocks({
const hasInstalledPluginContent = enableTriggerPlugin && hasPluginContent
const hasMarketplaceContent =
enableTriggerPlugin && enable_marketplace && marketplacePlugins.length > 0
enableTriggerPlugin && enable_marketplace && notInstalledPlugins.length > 0
const hasAnyContent =
hasStartBlocksContent ||
hasInstalledPluginContent ||
@@ -231,7 +234,7 @@ function AllStartBlocks({
<PluginList
ref={pluginRef}
wrapElemRef={wrapElemRef as RefObject<HTMLElement>}
list={marketplacePlugins}
list={notInstalledPlugins}
searchText={trimmedSearchText}
category={PluginCategoryEnum.trigger}
tags={tags}

View File

@@ -106,10 +106,14 @@ function DataSources({
],
)
const { data: marketplacePluginsData } = useMarketplacePlugins(marketplaceSearchParams)
const notInstalledPlugins = useMemo(
() => marketplacePluginsData?.pages.flatMap((page) => page.plugins) ?? [],
[marketplacePluginsData?.pages],
)
const notInstalledPlugins = useMemo(() => {
const installedPluginIds = new Set(dataSources.map((provider) => provider.plugin_id))
return (
marketplacePluginsData?.pages.flatMap((page) =>
page.plugins.filter((plugin) => !installedPluginIds.has(plugin.plugin_id)),
) ?? []
)
}, [dataSources, marketplacePluginsData?.pages])
return (
<div className={cn('w-100 max-w-full min-w-0', className)}>

View File

@@ -260,6 +260,25 @@ describe('AgentStrategySelector', () => {
expect(screen.queryByText('beta')).not.toBeInTheDocument()
})
it('excludes installed strategy plugins from marketplace results', async () => {
const user = userEvent.setup()
mocks.useMarketplacePlugins.mockReturnValue({
queryPluginsWithDebounced: mocks.queryPluginsWithDebounced,
plugins: [{ plugin_id: 'plugin-alpha' }, { plugin_id: 'market-agent' }],
})
render(<AgentStrategySelector onChange={vi.fn()} />)
await user.click(
screen
.getByText(/(?:^|\.)nodes\.agent\.strategy\.selectTip(?=$|:)/)
.closest('[aria-haspopup]')!,
)
expect(screen.getByText('alpha')).toBeInTheDocument()
expect(screen.getByTestId('plugin-list')).toHaveTextContent(':market-agent')
expect(screen.getByTestId('plugin-list')).not.toHaveTextContent('plugin-alpha')
})
it('maps the selected tool and closes the popover', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

View File

@@ -147,8 +147,12 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
const wrapElemRef = useRef<HTMLDivElement>(null)
const { queryPluginsWithDebounced: fetchPlugins, plugins: notInstalledPlugins = [] } =
const { queryPluginsWithDebounced: fetchPlugins, plugins: marketplacePlugins = [] } =
useMarketplacePlugins()
const notInstalledPlugins = useMemo(() => {
const installedPluginIds = new Set((stra.data ?? []).map((provider) => provider.plugin_id))
return marketplacePlugins.filter((plugin) => !installedPluginIds.has(plugin.plugin_id))
}, [marketplacePlugins, stra.data])
useEffect(() => {
if (!enable_marketplace) return

View File

@@ -39,4 +39,17 @@ describe('agent file icon helpers', () => {
expect(getFileIconType('archive.zip')).toBe('archive')
expect(getFileIconType('script.ts')).toBe('code')
})
it.each([
['references/notes.txt', undefined],
['references/NOTES.TXT', undefined],
['references/notes.txt', 'text/plain'],
['references/notes.txt', 'application/octet-stream'],
])('should identify %s as text regardless of MIME metadata (%s)', (fileName, mimeType) => {
expect(getFileIconType(fileName, mimeType)).toBe('text')
})
it('should keep unrecognized binary files as generic files', () => {
expect(getFileIconType('data.bin', 'application/octet-stream')).toBe('file')
})
})

View File

@@ -63,7 +63,7 @@ export function getFileIconType(fileName: string, mimeType?: string | null): Fil
if (tableFileExtensions.has(extension)) return 'table'
if (archiveFileExtensions.has(extension)) return 'archive'
if (codeFileExtensions.has(extension)) return 'code'
if (mimeType?.startsWith('text/')) return 'text'
if (extension === 'txt' || mimeType?.startsWith('text/')) return 'text'
return 'file'
}

View File

@@ -1,5 +1,5 @@
import type { SkillDetailResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import {
@@ -447,6 +447,213 @@ describe('SkillDetailPage builder', () => {
expect(sendButton).toBeDisabled()
}, 15000)
it('uses an available model instead of a default model that no longer exists', async () => {
const user = userEvent.setup()
mocks.defaultTextGenerationModel = {
provider: { provider: 'langgenius/openai/openai' },
model: 'gpt-5-chat-latest',
}
renderSkillDetailPage()
const promptInput = await screen.findByPlaceholderText(
'skill.skillManagement.detail.builder.modifyPlaceholder',
)
await user.type(promptInput, 'Update the skill{Enter}')
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
expect.objectContaining({
model: expect.objectContaining({
provider: 'langgenius/openai/openai',
model: 'gpt-5.5',
}),
}),
)
expect(mocks.modelParameterRulesFetch).toHaveBeenCalled()
const requestedModels = mocks.modelParameterRulesFetch.mock.calls.map(([input]) => {
const url = input instanceof Request ? input.url : String(input)
return new URL(url).searchParams.get('model')
})
expect(requestedModels).toEqual(['gpt-5.5'])
})
it('does not request parameters or send with an unavailable default and no usable model', async () => {
const user = userEvent.setup()
mocks.defaultTextGenerationModel = {
provider: { provider: 'langgenius/openai/openai' },
model: 'gpt-5-chat-latest',
}
mocks.textGenerationModelList = []
mocks.skillDetail = createDefaultSkillDraftDetail()
renderSkillDetailPage()
const promptInput = await screen.findByPlaceholderText(
'skill.skillManagement.detail.builder.placeholder',
)
expect(
screen.getByRole('button', {
name: 'skill.skillManagement.detail.builder.send',
}),
).toBeDisabled()
expect(
screen.getByRole('button', {
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
}),
).toBeDisabled()
await user.type(promptInput, 'Create a skill{Enter}')
expect(mocks.sendSkillAssistMessage).not.toHaveBeenCalled()
expect(mocks.modelParameterRulesFetch).not.toHaveBeenCalled()
})
it('keeps an available default model instead of replacing it with the first model', async () => {
const user = userEvent.setup()
mocks.textGenerationModelList[0]!.models.unshift({
model: 'gpt-4.1',
status: 'active',
})
renderSkillDetailPage()
const promptInput = await screen.findByPlaceholderText(
'skill.skillManagement.detail.builder.modifyPlaceholder',
)
await user.type(promptInput, 'Update the skill{Enter}')
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
expect.objectContaining({
model: expect.objectContaining({ model: 'gpt-5.5' }),
}),
)
expect(mocks.toastError).not.toHaveBeenCalled()
})
it.each([
{ target: 'provider', status: 'disabled' },
{ target: 'model', status: 'disabled' },
{ target: 'model', status: 'credential-removed' },
])('does not use a default whose $target has status $status', async ({ target, status }) => {
const user = userEvent.setup()
const defaultProvider = mocks.textGenerationModelList[0]!
if (target === 'provider') defaultProvider.status = status
else defaultProvider.models[0]!.status = status
mocks.textGenerationModelList.push({
provider: 'langgenius/anthropic/anthropic',
status: 'active',
models: [{ model: 'claude-sonnet-4-6', status: 'active' }],
})
renderSkillDetailPage()
const promptInput = await screen.findByPlaceholderText(
'skill.skillManagement.detail.builder.modifyPlaceholder',
)
await user.type(promptInput, 'Update the skill{Enter}')
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
expect.objectContaining({
model: expect.objectContaining({
provider: 'langgenius/anthropic/anthropic',
model: 'claude-sonnet-4-6',
}),
}),
)
expect(mocks.modelParameterRulesFetch).toHaveBeenCalledTimes(1)
const request = mocks.modelParameterRulesFetch.mock.calls[0]![0]
const url = new URL(request instanceof Request ? request.url : String(request))
expect(url.pathname).toContain('/model-providers/langgenius/anthropic/anthropic/')
expect(url.searchParams.get('model')).toBe('claude-sonnet-4-6')
})
it('waits for available models before requesting parameters or allowing Builder sends', async () => {
const user = userEvent.setup()
let resolveModels: (response: { data: typeof mocks.textGenerationModelList }) => void = () => {}
const modelListResponse = new Promise<{ data: typeof mocks.textGenerationModelList }>(
(resolve) => {
resolveModels = resolve
},
)
mocks.textGenerationModelListGet.mockReturnValue(modelListResponse)
mocks.skillDetail = createDefaultSkillDraftDetail()
renderSkillDetailPage()
const promptInput = await screen.findByPlaceholderText(
'skill.skillManagement.detail.builder.placeholder',
)
const sendButton = screen.getByRole('button', {
name: 'skill.skillManagement.detail.builder.send',
})
const suggestion = screen.getByRole('button', {
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
})
await user.type(promptInput, 'Create a skill{Enter}')
expect(sendButton).toBeDisabled()
expect(suggestion).toBeDisabled()
expect(mocks.sendSkillAssistMessage).not.toHaveBeenCalled()
expect(mocks.modelParameterRulesFetch).not.toHaveBeenCalled()
await act(async () => {
resolveModels({ data: mocks.textGenerationModelList })
await modelListResponse
})
await waitFor(() => expect(sendButton).toBeEnabled())
expect(suggestion).toBeEnabled()
await user.keyboard('{Enter}')
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
expect.objectContaining({ model: expect.objectContaining({ model: 'gpt-5.5' }) }),
)
expect(mocks.modelParameterRulesFetch).toHaveBeenCalledTimes(1)
})
it('preserves user-edited parameters for an available Builder model', async () => {
const user = userEvent.setup()
mocks.modelParameterRulesFetch.mockImplementation(async () =>
Response.json({
data: [
{
name: 'temperature',
label: { en_US: 'Temperature' },
help: { en_US: 'Control randomness' },
type: 'float',
min: 0,
max: 2,
default: 0.7,
precision: 1,
},
],
}),
)
renderSkillDetailPage()
await user.click(
await screen.findByRole('button', {
name: 'common.modelProvider.modelSettings',
}),
)
const dialog = await screen.findByRole('dialog', {
name: 'common.modelProvider.modelSettings',
})
const temperature = await within(dialog).findByRole('spinbutton', { name: 'Temperature' })
await user.clear(temperature)
await user.type(temperature, '1.2')
await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' }))
await user.type(
screen.getByPlaceholderText('skill.skillManagement.detail.builder.modifyPlaceholder'),
'Update the skill{Enter}',
)
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
expect.objectContaining({
model: {
provider: 'langgenius/openai/openai',
model: 'gpt-5.5',
model_settings: { temperature: 1.2 },
},
}),
)
})
it('uploads image attachments in Skill Builder', async () => {
const user = userEvent.setup({ applyAccept: false })
mocks.uploadSkillFile.mockResolvedValue({

View File

@@ -41,7 +41,7 @@ describe('SkillDetailPage metadata', () => {
).not.toBeInTheDocument()
})
it('saves selected workspace tags when the selector closes', async () => {
it('previews selected workspace tags immediately and saves once when the selector closes', async () => {
const user = userEvent.setup()
renderSkillDetailPage()
@@ -51,6 +51,21 @@ describe('SkillDetailPage metadata', () => {
}),
)
await user.click(await screen.findByRole('option', { name: 'Search' }))
expect(
within(screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ })).getByText(
'Search',
),
).toBeInTheDocument()
expect(screen.getByRole('listbox')).toBeVisible()
expect(screen.getByRole('option', { name: 'Search' })).toHaveAttribute('aria-selected', 'true')
expect(
screen.getByRole('button', {
name: 'skill.skillManagement.detail.removeTag:{"tag":"Search"}',
}),
).toBeDisabled()
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
await waitFor(() => {
@@ -64,9 +79,10 @@ describe('SkillDetailPage metadata', () => {
expect.anything(),
)
})
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledTimes(1)
})
it('removes a selected tag when it is unchecked and the selector closes', async () => {
it('previews unchecked tags immediately and saves once when the selector closes', async () => {
const user = userEvent.setup()
mocks.skillDetail = createSkillDetail({
tags: ['Search', 'Productivity'],
@@ -79,6 +95,16 @@ describe('SkillDetailPage metadata', () => {
}),
)
await user.click(await screen.findByRole('option', { name: 'Search' }))
const sidebar = within(
screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ }),
)
expect(sidebar.queryByText('Search')).not.toBeInTheDocument()
expect(sidebar.getByText('Productivity')).toBeInTheDocument()
expect(screen.getByRole('listbox')).toBeVisible()
expect(screen.getByRole('option', { name: 'Search' })).toHaveAttribute('aria-selected', 'false')
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
await waitFor(() => {
@@ -91,6 +117,7 @@ describe('SkillDetailPage metadata', () => {
expect.anything(),
)
})
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledTimes(1)
})
it('renders an unmatched search as a create action instead of a tag checkbox', async () => {
@@ -117,7 +144,7 @@ describe('SkillDetailPage metadata', () => {
expect(screen.queryByRole('option', { name: 'BrandNew' })).not.toBeInTheDocument()
})
it('creates and binds an unmatched tag when the create action is selected', async () => {
it('previews a newly created tag immediately and binds it when the selector closes', async () => {
const user = userEvent.setup()
renderSkillDetailPage()
@@ -137,6 +164,19 @@ describe('SkillDetailPage metadata', () => {
name: "common.tag.create 'BrandNew'",
}),
)
expect(
within(screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ })).getByText(
'BrandNew',
),
).toBeInTheDocument()
expect(screen.getByRole('listbox')).toBeVisible()
expect(screen.getByRole('option', { name: 'BrandNew' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
await waitFor(() => {
@@ -149,9 +189,10 @@ describe('SkillDetailPage metadata', () => {
expect.anything(),
)
})
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledTimes(1)
})
it('shows an added tag only after the metadata request finishes', async () => {
it('keeps selected tags visible and prevents reopening while the metadata request is pending', async () => {
const user = userEvent.setup()
let resolveMutation: ((detail: SkillDetailResponse) => void) | undefined
mocks.skillDetailKey.mockReturnValue(['skill-detail'])
@@ -175,7 +216,23 @@ describe('SkillDetailPage metadata', () => {
await user.click(await screen.findByRole('option', { name: 'Search' }))
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
expect(screen.queryByText('Search')).not.toBeInTheDocument()
expect(
within(screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ })).getByText(
'Search',
),
).toBeInTheDocument()
const addTagButton = screen.getByRole('combobox', {
name: 'skill.skillManagement.detail.addTag',
})
expect(addTagButton).toBeDisabled()
expect(
screen.getByRole('button', {
name: 'skill.skillManagement.detail.removeTag:{"tag":"Search"}',
}),
).toBeDisabled()
await user.click(addTagButton)
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledTimes(1)
await act(async () => {
const nextDetail = createSkillDetail({
@@ -187,6 +244,61 @@ describe('SkillDetailPage metadata', () => {
})
expect(await screen.findByText('Search')).toBeInTheDocument()
expect(addTagButton).toBeEnabled()
})
it('restores persisted tags when saving the visible selection fails', async () => {
const user = userEvent.setup()
let rejectMutation: ((error: Error) => void) | undefined
mocks.skillDetailKey.mockReturnValue(['skill-detail'])
mocks.skillDetailQueryOptions.mockImplementation(() => ({
queryKey: ['skill-detail'],
queryFn: async () => mocks.skillDetail,
}))
mocks.skillDetail = createSkillDetail({ tags: ['Search'] })
mocks.skillMetadataMutationFn.mockImplementation(
() =>
new Promise<SkillDetailResponse>((_resolve, reject) => {
rejectMutation = reject
}),
)
renderSkillDetailPage()
await user.click(
await screen.findByRole('combobox', {
name: 'skill.skillManagement.detail.addTag',
}),
)
await user.click(await screen.findByRole('option', { name: 'Search' }))
await user.click(screen.getByRole('option', { name: 'Productivity' }))
await user.click(screen.getByTestId('skill-detail-sidebar-header'))
const sidebar = within(
screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ }),
)
expect(sidebar.queryByText('Search')).not.toBeInTheDocument()
expect(sidebar.getByText('Productivity')).toBeInTheDocument()
await act(async () => {
rejectMutation?.(new Error('Saving tags failed'))
})
expect(await sidebar.findByText('Search')).toBeInTheDocument()
expect(sidebar.queryByText('Productivity')).not.toBeInTheDocument()
expect(mocks.toastError).toHaveBeenCalledWith('skill.skillManagement.detail.updateTagsFailed')
await user.click(
screen.getByRole('combobox', {
name: 'skill.skillManagement.detail.addTag',
}),
)
expect(await screen.findByRole('option', { name: 'Search' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(screen.getByRole('option', { name: 'Productivity' })).toHaveAttribute(
'aria-selected',
'false',
)
})
it('opens tag management from the selector', async () => {
@@ -341,31 +453,62 @@ describe('SkillDetailPage metadata', () => {
expect(renameInput).toHaveValue('Untitled skill')
})
it('opens the returned duplicate for inline rename from the sidebar More menu', async () => {
const user = userEvent.setup()
mocks.duplicateSkillMutationFn.mockResolvedValue(createSkillDetail({ id: 'copied-skill' }))
renderSkillDetailPage()
const moreButton = await screen.findByRole('button', {
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
})
await user.click(moreButton)
await user.click(screen.getByRole('menuitem', { name: 'common.operation.duplicate' }))
await waitFor(() => {
expect(mocks.duplicateSkillMutationFn).toHaveBeenCalledWith(
{ params: { skill_id: 'skill-1' } },
expect.anything(),
)
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('skill.skillManagement.duplicateSuccess')
expect(mocks.routerPush).toHaveBeenCalledWith('/skills/copied-skill?rename=true')
})
it('stays on the original detail when duplication fails', async () => {
const user = userEvent.setup()
mocks.duplicateSkillMutationFn.mockRejectedValue(new Error('Duplicate failed'))
renderSkillDetailPage()
await user.click(
await screen.findByRole('button', {
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
}),
)
await user.click(screen.getByRole('menuitem', { name: 'common.operation.duplicate' }))
await waitFor(() => {
expect(mocks.toastError).toHaveBeenCalledWith('skill.skillManagement.duplicateFailed')
})
expect(mocks.routerPush).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'common.operation.rename' })).toHaveTextContent(
'Untitled skill',
)
})
it.each(['version-1', null])(
'duplicates and exports the current skill with published version %s from the sidebar More menu',
'exports the current skill with published version %s from the sidebar More menu',
async (publishedVersionId) => {
const user = userEvent.setup()
const archive = new Blob(['archive'], { type: 'application/zip' })
mocks.skillDetail = createSkillDetail({ latest_published_version_id: publishedVersionId })
mocks.duplicateSkillMutationFn.mockResolvedValue({})
mocks.fetchSkillArchiveBlob.mockResolvedValue(archive)
mocks.skillDetail = createSkillDetail({ latest_published_version_id: publishedVersionId })
renderSkillDetailPage()
const moreButton = await screen.findByRole('button', {
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
})
await user.click(moreButton)
await user.click(screen.getByRole('menuitem', { name: 'common.operation.duplicate' }))
await waitFor(() => {
expect(mocks.duplicateSkillMutationFn).toHaveBeenCalledWith(
{ params: { skill_id: 'skill-1' } },
expect.anything(),
)
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('skill.skillManagement.duplicateSuccess')
await user.click(moreButton)
await user.click(
await screen.findByRole('button', {
name: 'skill.skillManagement.moreActions:{"name":"Untitled skill"}',
}),
)
await user.click(screen.getByRole('menuitem', { name: 'common.operation.export' }))
await waitFor(() => {
@@ -378,6 +521,84 @@ describe('SkillDetailPage metadata', () => {
},
)
it('does not start inline rename on an ordinary detail visit', async () => {
const { onUrlUpdate } = renderSkillDetailPage()
expect(
await screen.findByRole('button', { name: 'common.operation.rename' }),
).toHaveTextContent('Untitled skill')
expect(
screen.queryByRole('textbox', { name: 'common.operation.rename' }),
).not.toBeInTheDocument()
expect(onUrlUpdate).not.toHaveBeenCalled()
})
it('starts inline rename after the copied detail loads and consumes the request once', async () => {
const user = userEvent.setup()
let resolveDetail: (detail: SkillDetailResponse) => void = () => {}
const detailPromise = new Promise<SkillDetailResponse>((resolve) => {
resolveDetail = resolve
})
mocks.skillDetailQueryOptions.mockImplementation((options) => ({
queryKey: ['skill-detail', options],
queryFn: () => detailPromise,
}))
const { onUrlUpdate, queryClient } = renderSkillDetailPage({
searchParams: '?rename=true&source=list',
strict: true,
})
expect(
screen.queryByRole('textbox', { name: 'common.operation.rename' }),
).not.toBeInTheDocument()
expect(onUrlUpdate).not.toHaveBeenCalled()
await act(async () => resolveDetail(createSkillDetail()))
const renameInput = await screen.findByRole('textbox', { name: 'common.operation.rename' })
expect(renameInput).toHaveFocus()
expect(renameInput).toHaveValue('Untitled skill')
expect(renameInput).toHaveProperty('selectionStart', 0)
expect(renameInput).toHaveProperty('selectionEnd', 'Untitled skill'.length)
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.has('rename')).toBe(false)
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('source')).toBe('list')
await user.keyboard('{Escape}')
expect(screen.getByRole('button', { name: 'common.operation.rename' })).toBeInTheDocument()
await act(async () => queryClient.invalidateQueries())
expect(
screen.queryByRole('textbox', { name: 'common.operation.rename' }),
).not.toBeInTheDocument()
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
})
it('saves the automatically activated name editor without activating it again', async () => {
const user = userEvent.setup()
const { onUrlUpdate, queryClient } = renderSkillDetailPage({ searchParams: '?rename=true' })
const renameInput = await screen.findByRole('textbox', { name: 'common.operation.rename' })
await user.clear(renameInput)
await user.type(renameInput, 'Renamed duplicate{Enter}')
await waitFor(() => {
expect(screen.getByRole('button', { name: 'common.operation.rename' })).toHaveTextContent(
'Renamed duplicate',
)
})
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({ display_name: 'Renamed duplicate' }),
}),
expect.anything(),
)
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.has('rename')).toBe(false)
await act(async () => queryClient.invalidateQueries())
expect(
screen.queryByRole('textbox', { name: 'common.operation.rename' }),
).not.toBeInTheDocument()
})
it('requires the display name before deleting a referenced skill from the sidebar', async () => {
const user = userEvent.setup()
mocks.skillDetail = createSkillDetail({ reference_count: 1 })

View File

@@ -1,12 +1,15 @@
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import {
createFileTabSkillDetail,
createReferencePickerSkillDetail,
createSkillDetail,
createSkillVersion,
getFileTabButton,
getFileTreeButton,
getLiveMarkdownEditor,
@@ -78,6 +81,61 @@ describe('SkillDetailPage navigation', () => {
).toBeInTheDocument()
})
it('opens another skill in its editable draft without retaining the original view state', async () => {
const { default: SkillDetailPageRoute } = await import('../detail-page')
const user = userEvent.setup()
const version = createSkillVersion({ id: 'original-version', version_name: 'Original version' })
mocks.skillVersionsQueryOptions.mockImplementation((options) => ({
queryKey: ['skill-versions', options],
queryFn: async () => ({ data: [version] }),
}))
mocks.skillVersionDetailQueryOptions.mockImplementation((options) => ({
queryKey: ['skill-version-detail', options],
queryFn: async () => ({
...version,
files: createSkillDetail().files,
}),
}))
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
const { wrapper } = createNuqsTestWrapper()
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<SkillDetailPageRoute />
</QueryClientProvider>,
{ wrapper },
)
await user.click(
await screen.findByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }),
)
await user.click(await screen.findByRole('button', { name: /Original version/ }))
expect(
screen.queryByRole('button', { name: 'common.operation.rename' }),
).not.toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'skill.skillManagement.detail.collapseSidebar' }),
)
expect(screen.queryByTestId('skill-detail-sidebar-header')).not.toBeInTheDocument()
mocks.routeSkillId = 'copied-skill'
mocks.skillDetail = createSkillDetail({ id: 'copied-skill', display_name: 'Copied skill' })
rerender(
<QueryClientProvider client={queryClient}>
<SkillDetailPageRoute />
</QueryClientProvider>,
)
expect(await screen.findByTestId('skill-detail-sidebar-header')).toBeInTheDocument()
expect(
await screen.findByRole('button', { name: 'common.operation.rename' }),
).toHaveTextContent('Copied skill')
expect(
screen.queryByRole('button', { name: 'skill.skillManagement.detail.restoreVersion' }),
).not.toBeInTheDocument()
})
it('shows the sidebar while the collapsed rail is hovered', async () => {
const user = userEvent.setup()
renderSkillDetailPage()

View File

@@ -11,7 +11,8 @@ import { detectPlatform } from '@tanstack/react-hotkeys'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { StrictMode } from 'react'
import { vi } from 'vite-plus/test'
import { afterEach, vi } from 'vite-plus/test'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { SkillDetailPage } from '../detail/page'
export const primaryModifier = detectPlatform() === 'mac' ? { metaKey: true } : { ctrlKey: true }
@@ -27,9 +28,12 @@ const mocks = vi.hoisted(() => ({
publishSkillMutationFn: vi.fn(),
publishSkillMutationOptions: vi.fn(),
routerPush: vi.fn(),
routeSkillId: 'skill-1',
restoreSkillMutationFn: vi.fn(),
saveDraftFileMutationFn: vi.fn(),
sendSkillAssistMessage: vi.fn(),
modelParameterRulesFetch: vi.fn<typeof fetch>(),
textGenerationModelListGet: vi.fn(),
defaultTextGenerationModel: undefined as
| { provider: { provider: string }; model: string }
| undefined,
@@ -112,6 +116,7 @@ vi.mock('@/app/components/main-nav/components/help-menu', () => ({
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
useDefaultModel: () => ({
data: mocks.defaultTextGenerationModel,
}),
@@ -127,14 +132,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
SplitModelSelector: () => <button type="button">model-settings</button>,
}))
vi.mock('@/service/use-common', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/use-common')>()),
useModelParameterRules: () => ({
data: { data: [] },
isLoading: false,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ onChange, value }: { onChange?: (value: string) => void; value: string }) => (
<textarea
@@ -175,6 +172,7 @@ vi.mock('@/next/link', () => ({
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ skillId: mocks.routeSkillId }),
useRouter: () => ({
push: mocks.routerPush,
}),
@@ -489,11 +487,15 @@ export function renderSkillDetailPage({
mutations: { retry: false },
},
}),
searchParams = '',
strict = false,
}: {
queryClient?: QueryClient
searchParams?: string
strict?: boolean
} = {}) {
const { wrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
return {
...render(
<QueryClientProvider client={queryClient}>
@@ -505,7 +507,9 @@ export function renderSkillDetailPage({
<SkillDetailPage skillId="skill-1" />
)}
</QueryClientProvider>,
{ wrapper },
),
onUrlUpdate,
queryClient,
}
}
@@ -676,6 +680,14 @@ export function getMocks() {
export function resetDetailPageFixture() {
vi.useRealTimers()
vi.resetAllMocks()
mocks.modelParameterRulesFetch.mockImplementation(async (input) => {
const url = input instanceof Request ? input.url : String(input)
if (!url.includes('/models/parameter-rules'))
throw new Error(`Unexpected Skill detail request: ${url}`)
return Response.json({ data: [] })
})
vi.stubGlobal('fetch', mocks.modelParameterRulesFetch)
mocks.routeSkillId = 'skill-1'
mocks.defaultTextGenerationModel = {
provider: {
provider: 'langgenius/openai/openai',
@@ -694,6 +706,9 @@ export function resetDetailPageFixture() {
],
},
]
mocks.textGenerationModelListGet.mockImplementation(async () => ({
data: mocks.textGenerationModelList,
}))
mocks.skillDetail = createSkillDetail()
mocks.skillDetailGetFn.mockImplementation(async () => mocks.skillDetail)
mocks.skillDetailKey.mockImplementation((options) => ['skill-detail', options])
@@ -845,6 +860,10 @@ export function resetDetailPageFixture() {
})
}
afterEach(() => {
vi.unstubAllGlobals()
})
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
@@ -856,7 +875,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
>
}) =>
options.queryKey[0].includes('modelTypes')
? { data: mocks.textGenerationModelList, isPending: false }
? actual.useQuery({ ...options, queryFn: mocks.textGenerationModelListGet })
: actual.useQuery(options),
}
})

View File

@@ -251,6 +251,14 @@ function renderSkillsPage(queryClient = createTestQueryClient()) {
)
}
async function openImportDialog(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole('button', { name: 'skill.skillManagement.import' }))
return screen.findByRole('dialog', {
name: 'skill.skillManagement.importDialog.title',
})
}
describe('SkillsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -769,35 +777,200 @@ describe('SkillsPage', () => {
})
})
it('imports a package file and navigates to the imported skill', async () => {
const user = userEvent.setup()
const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries')
const { container } = renderSkillsPage()
it.each(['toolbar', 'empty state'])(
'shows package requirements from the %s import entry',
async (entry) => {
const user = userEvent.setup()
if (entry === 'empty state') mocks.skillPages = [[]]
renderSkillsPage()
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
expect(fileInput).not.toBeNull()
const file = new File(['skill'], 'refund.skill', { type: 'application/zip' })
await user.upload(fileInput!, file)
await waitFor(() => {
expect(mocks.importSkillMutationFn).toHaveBeenCalledWith(
{
body: {
file,
},
},
expect.anything(),
await user.click(
await screen.findByRole('button', {
name:
entry === 'toolbar'
? 'skill.skillManagement.import'
: /skill\.skillManagement\.emptyAction\.importTitle/,
}),
)
const dialog = await screen.findByRole('dialog', {
name: 'skill.skillManagement.importDialog.title',
})
expect(dialog).toHaveAccessibleDescription('skill.skillManagement.importDialog.description')
expect(
within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }),
).toBeDisabled()
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
},
)
it.each(['refund.skill', 'refund.zip'])(
'stages %s until confirmation, then navigates to the imported skill',
async (fileName) => {
const user = userEvent.setup()
const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries')
renderSkillsPage()
const dialog = await openImportDialog(user)
const file = new File(['skill'], fileName, { type: 'application/zip' })
await user.upload(
within(dialog).getByLabelText('skill.skillManagement.importDialog.browse'),
file,
)
expect(within(dialog).getByText(fileName)).toBeInTheDocument()
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }))
await waitFor(() => {
expect(mocks.importSkillMutationFn).toHaveBeenCalledWith(
{
body: {
file,
},
},
expect.anything(),
)
})
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.importSuccess')
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'query' }] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'infinite' }] })
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['skill-tags', { type: 'query' }],
})
expect(mocks.push).toHaveBeenCalledWith('/skills/imported-skill')
invalidateQueries.mockRestore()
},
)
it.each(['common.operation.cancel', 'common.operation.close'])(
'clears the staged package when dismissed with %s and reopened',
async (closeAction) => {
const user = userEvent.setup()
renderSkillsPage()
const dialog = await openImportDialog(user)
await user.upload(
within(dialog).getByLabelText('skill.skillManagement.importDialog.browse'),
new File(['skill'], 'refund.skill', { type: 'application/zip' }),
)
await user.click(within(dialog).getByRole('button', { name: closeAction }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
const reopenedDialog = await openImportDialog(user)
expect(within(reopenedDialog).queryByText('refund.skill')).not.toBeInTheDocument()
expect(
within(reopenedDialog).getByRole('button', { name: 'skill.skillManagement.import' }),
).toBeDisabled()
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
},
)
it('removes a staged package and allows selecting that same file again', async () => {
const user = userEvent.setup()
renderSkillsPage()
const dialog = await openImportDialog(user)
const file = new File(['skill'], 'refund.skill', { type: 'application/zip' })
const fileInput = within(dialog).getByLabelText('skill.skillManagement.importDialog.browse')
await user.upload(fileInput, file)
await user.click(within(dialog).getByRole('button', { name: 'common.operation.remove' }))
expect(within(dialog).queryByText('refund.skill')).not.toBeInTheDocument()
expect(
within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }),
).toBeDisabled()
await user.upload(fileInput, file)
expect(within(dialog).getByText('refund.skill')).toBeInTheDocument()
expect(
within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }),
).toBeEnabled()
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
})
it.each([
['an unsupported extension', ['refund.txt']],
['multiple packages', ['refund.skill', 'support.zip']],
])('rejects dropping %s and allows correction before importing', async (_label, fileNames) => {
const user = userEvent.setup()
renderSkillsPage()
const dialog = await openImportDialog(user)
const dropZone = within(dialog).getByRole('group', {
name: 'skill.skillManagement.importDialog.title',
})
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.importSuccess')
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'query' }] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['skills', { type: 'infinite' }] })
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['skill-tags', { type: 'query' }],
fireEvent.drop(dropZone, {
dataTransfer: {
files: fileNames.map((fileName) => new File(['skill'], fileName)),
types: ['Files'],
},
})
expect(mocks.push).toHaveBeenCalledWith('/skills/imported-skill')
invalidateQueries.mockRestore()
expect(within(dialog).getByRole('alert')).toHaveTextContent(
'skill.skillManagement.importDialog.invalidFile',
)
expect(
within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }),
).toBeDisabled()
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
const file = new File(['skill'], 'refund.zip', { type: 'application/zip' })
fireEvent.drop(dropZone, { dataTransfer: { files: [file], types: ['Files'] } })
expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument()
expect(within(dialog).getByText('refund.zip')).toBeInTheDocument()
expect(mocks.importSkillMutationFn).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }))
await waitFor(() =>
expect(mocks.importSkillMutationFn).toHaveBeenCalledWith(
{ body: { file } },
expect.anything(),
),
)
})
it('keeps the package stable and prevents repeat submission or dismissal while importing', async () => {
const user = userEvent.setup()
let completeImport: ((skill: SkillResponse) => void) | undefined
mocks.importSkillMutationFn.mockImplementationOnce(
() =>
new Promise<SkillResponse>((resolve) => {
completeImport = resolve
}),
)
renderSkillsPage()
const dialog = await openImportDialog(user)
const fileInput = within(dialog).getByLabelText('skill.skillManagement.importDialog.browse')
const file = new File(['skill'], 'refund.skill', { type: 'application/zip' })
await user.upload(fileInput, file)
const importButton = within(dialog).getByRole('button', {
name: 'skill.skillManagement.import',
})
await user.click(importButton)
await waitFor(() => expect(importButton).toHaveAttribute('aria-disabled', 'true'))
expect(fileInput).toBeDisabled()
const cancelButton = within(dialog).getByRole('button', { name: 'common.operation.cancel' })
const closeButton = within(dialog).getByRole('button', { name: 'common.operation.close' })
expect(cancelButton).toBeDisabled()
expect(closeButton).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'common.operation.remove' })).toBeDisabled()
await user.click(importButton)
await user.click(cancelButton)
await user.click(closeButton)
await user.keyboard('{Escape}')
fireEvent.drop(
within(dialog).getByRole('group', { name: 'skill.skillManagement.importDialog.title' }),
{
dataTransfer: { files: [new File(['other'], 'replacement.zip')], types: ['Files'] },
},
)
expect(dialog).toBeInTheDocument()
expect(within(dialog).getByText('refund.skill')).toBeInTheDocument()
expect(within(dialog).queryByText('replacement.zip')).not.toBeInTheDocument()
expect(mocks.importSkillMutationFn).toHaveBeenCalledTimes(1)
completeImport?.(createSkill({ id: 'imported-skill' }))
await waitFor(() => expect(mocks.push).toHaveBeenCalledWith('/skills/imported-skill'))
})
it.each([
@@ -831,35 +1004,54 @@ describe('SkillsPage', () => {
])('explains import errors for $error.code', async ({ error, message }) => {
const user = userEvent.setup()
mocks.importSkillMutationFn.mockRejectedValueOnce(error)
const { container } = renderSkillsPage()
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
renderSkillsPage()
const dialog = await openImportDialog(user)
const file = new File(['skill'], 'refund.skill', { type: 'application/zip' })
await user.upload(fileInput!, file)
await user.upload(
within(dialog).getByLabelText('skill.skillManagement.importDialog.browse'),
file,
)
await user.click(within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(message)
})
})
it('explains an import error returned as a Response body', async () => {
it('keeps the package available for retry after explaining an error returned as a Response body', async () => {
const user = userEvent.setup()
mocks.importSkillMutationFn.mockRejectedValueOnce(
new Response(JSON.stringify({ message: 'Skill package must contain SKILL.md' }), {
status: 400,
}),
)
const { container } = renderSkillsPage()
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
renderSkillsPage()
const dialog = await openImportDialog(user)
const file = new File(['skill'], 'refund.skill', { type: 'application/zip' })
await user.upload(fileInput!, new File(['skill'], 'refund.skill', { type: 'application/zip' }))
await user.upload(
within(dialog).getByLabelText('skill.skillManagement.importDialog.browse'),
file,
)
await user.click(within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('skill.skillManagement.errors.missingSkillMd')
})
expect(within(dialog).getByText('refund.skill')).toBeInTheDocument()
expect(mocks.push).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: 'skill.skillManagement.import' }))
await waitFor(() => expect(mocks.push).toHaveBeenCalledWith('/skills/imported-skill'))
expect(mocks.importSkillMutationFn).toHaveBeenCalledTimes(2)
expect(mocks.importSkillMutationFn).toHaveBeenLastCalledWith(
{ body: { file } },
expect.anything(),
)
})
it('duplicates a skill from the card action menu', async () => {
it('opens the returned duplicate for inline rename from the card action menu', async () => {
const user = userEvent.setup()
renderSkillsPage()
@@ -881,6 +1073,25 @@ describe('SkillsPage', () => {
)
})
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.duplicateSuccess')
expect(mocks.push).toHaveBeenCalledWith('/skills/duplicated-skill?rename=true')
})
it('stays on the skill list when duplication fails', async () => {
const user = userEvent.setup()
mocks.duplicateSkillMutationFn.mockRejectedValue(new Error('Duplicate failed'))
renderSkillsPage()
await user.click(
await screen.findByRole('button', {
name: 'skill.skillManagement.moreActions:{"name":"Refund approval"}',
}),
)
await user.click(await screen.findByText('common.operation.duplicate'))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('skill.skillManagement.duplicateFailed')
})
expect(mocks.push).not.toHaveBeenCalled()
})
it('exports a published skill from the card action menu', async () => {

View File

@@ -6,5 +6,5 @@ import { SkillDetailPage } from './detail/page'
export default function SkillDetailPageRoute() {
const { skillId } = useParams<{ skillId: string }>()
return <SkillDetailPage skillId={skillId} />
return <SkillDetailPage key={skillId} skillId={skillId} />
}

View File

@@ -371,7 +371,18 @@ export function SkillBuilderPanel({
}
: undefined
const [selectedModel, setSelectedModel] = useState<SkillBuilderModel | undefined>()
const activeSelectedModel = selectedModel ?? defaultBuilderModel ?? fallbackModel
const activeSelectedModel = [selectedModel, defaultBuilderModel, fallbackModel].find(
(candidate) =>
candidate &&
textGenerationModelList.some(
(provider) =>
provider.provider === candidate.provider &&
provider.status === ModelStatusEnum.active &&
provider.models.some(
(model) => model.model === candidate.model && model.status === ModelStatusEnum.active,
),
),
)
const canSendBuilderMessage = !!activeSelectedModel?.provider && !!activeSelectedModel?.model
const suggestions = [
t(($) => $['skillManagement.detail.builder.exampleIssueTriage']),

View File

@@ -61,6 +61,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay, matchesKeyboardEvent, useHotkey } from '@tanstack/react-hotkeys'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import copy from 'copy-to-clipboard'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
@@ -189,7 +190,17 @@ export function FileTree({
const [draggingPaths, setDraggingPaths] = useState<string[]>([])
const [dropTarget, setDropTarget] = useState<SkillDropTarget>()
const [collapsedFolderPaths, setCollapsedFolderPaths] = useState<string[]>([])
const [skillRenameEditing, setSkillRenameEditing] = useState(false)
const [renameRequested, setRenameRequested] = useQueryState(
'rename',
parseAsBoolean.withDefault(false),
)
const [skillRenameEditing, setSkillRenameEditing] = useState(
() => canEdit && !readonly && renameRequested,
)
useEffect(() => {
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Consume the URL navigation flag after the loaded detail initializes its local editor.
if (renameRequested) void setRenameRequested(null)
}, [renameRequested, setRenameRequested])
const [selectedPaths, setSelectedPaths] = useState<string[]>([])
const [selectionAnchorPath, setSelectionAnchorPath] = useState<string>()
const [clipboard, setClipboard] = useState<SkillFileClipboard>()

View File

@@ -212,6 +212,7 @@ export function SkillDetailSidebarActions({
const { t: tCommon } = useTranslation('common')
const queryClient = useQueryClient()
const [deleteOpen, setDeleteOpen] = useState(false)
const router = useRouter()
const duplicateMutation = useMutation(
consoleQuery.workspaces.current.skills.bySkillId.duplicate.post.mutationOptions(),
)
@@ -235,9 +236,10 @@ export function SkillDetailSidebarActions({
},
},
{
onSuccess: () => {
onSuccess: (copiedSkill) => {
toast.success(t(($) => $['skillManagement.duplicateSuccess']))
invalidateSkillListQueries(queryClient)
router.push(`/skills/${copiedSkill.id}?rename=true`)
},
onError: () => {
toast.error(t(($) => $['skillManagement.duplicateFailed']))

View File

@@ -32,6 +32,7 @@ import { SkillPublishShortcut } from './publish-bar'
import {
invalidateSkillDetail,
runSkillFileMutation,
setSkillDetailCache,
SKILL_TAG_CREATE_OPTION_PREFIX,
} from './shared'
@@ -54,9 +55,11 @@ export function SkillTagsEditor({
const [addOpen, setAddOpen] = useState(false)
const [showTagManagement, setShowTagManagement] = useState(false)
const [tagSearch, setTagSearch] = useState('')
const [draftTags, setDraftTags] = useState<string[]>([])
const [draftTags, setDraftTags] = useState<string[] | null>(null)
const [isSavingTags, setIsSavingTags] = useState(false)
const persistedTags = useMemo(() => detail?.tags ?? [], [detail?.tags])
const tags = persistedTags
const visibleTags = draftTags ?? tags
const metadataMutation = useMutation(
consoleQuery.workspaces.current.skills.bySkillId.patch.mutationOptions(),
)
@@ -81,7 +84,7 @@ export function SkillTagsEditor({
for (const tag of tags) addOption(tag)
for (const tag of tagsQuery.data?.data ?? []) addOption(tag.tag)
for (const tag of draftTags) addOption(tag)
for (const tag of draftTags ?? []) addOption(tag)
const hasExactMatch = options.some(
(tag) => tag.name.toLocaleLowerCase() === normalizedTagSearch.toLocaleLowerCase(),
)
@@ -106,10 +109,11 @@ export function SkillTagsEditor({
}),
[tagOptions],
)
const draftTagIds = useMemo(() => draftTags.map(getSkillTagOptionId), [draftTags])
const draftTagIds = useMemo(() => visibleTags.map(getSkillTagOptionId), [visibleTags])
const saveTags = (nextTags: string[]) => {
if (!detail || metadataMutation.isPending) return
if (!detail || isSavingTags) return
setIsSavingTags(true)
void runSkillFileMutation(fileMutationCoordinator, (expectedUpdatedAt) =>
metadataMutation.mutateAsync({
@@ -122,7 +126,8 @@ export function SkillTagsEditor({
},
}),
)
.then(() => {
.then((nextDetail) => {
setSkillDetailCache(queryClient, skillId, nextDetail)
const addedTag = nextTags.some((tag) => !tags.includes(tag))
toast.success(
addedTag
@@ -141,9 +146,15 @@ export function SkillTagsEditor({
invalidateSkillDetail(queryClient, skillId)
toast.error(t(($) => $['skillManagement.detail.updateTagsFailed']))
})
.finally(() => {
setDraftTags(null)
setIsSavingTags(false)
})
}
const handleOpenChange = (open: boolean) => {
if (isSavingTags) return
if (open) {
setDraftTags(tags)
setTagSearch('')
@@ -153,10 +164,13 @@ export function SkillTagsEditor({
setAddOpen(false)
setTagSearch('')
if (!draftTags) return
const draftTagSet = new Set(draftTags)
const tagsChanged =
tags.length !== draftTags.length || tags.some((tag) => !draftTagSet.has(tag))
if (tagsChanged) saveTags(draftTags)
else setDraftTags(null)
}
const renderTagBadge = (tag: string) => (
@@ -168,6 +182,7 @@ export function SkillTagsEditor({
{!readonly && (
<button
type="button"
disabled={addOpen || isSavingTags}
aria-label={t(($) => $['skillManagement.detail.removeTag'], { tag })}
className="flex h-3 max-w-0 shrink-0 cursor-pointer items-center justify-center overflow-hidden text-text-quaternary opacity-0 outline-hidden transition-[max-width,margin,opacity] group-hover/tag:ml-0.5 group-hover/tag:max-w-3 group-hover/tag:opacity-100 hover:text-text-secondary focus-visible:ml-0.5 focus-visible:max-w-3 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => saveTags(tags.filter((currentTag) => currentTag !== tag))}
@@ -181,7 +196,7 @@ export function SkillTagsEditor({
return (
<>
<div className="mt-3 flex flex-wrap items-center gap-1">
{tags.map(renderTagBadge)}
{visibleTags.map(renderTagBadge)}
{!readonly && (
<Combobox<string, true, TagComboboxItem>
items={tagItems}
@@ -196,7 +211,7 @@ export function SkillTagsEditor({
})
const createOption = createOptionId ? tagOptionById.get(createOptionId) : undefined
if (createOption && isCreateTagOption(createOption)) {
setDraftTags((currentTags) => [...currentTags, createOption.name])
setDraftTags((currentTags) => [...(currentTags ?? tags), createOption.name])
setTagSearch('')
return
}
@@ -216,16 +231,16 @@ export function SkillTagsEditor({
>
<ComboboxTrigger
icon={false}
disabled={!detail}
disabled={!detail || isSavingTags}
aria-label={t(($) => $['skillManagement.detail.addTag'])}
className={cn(
'h-4.5 w-auto min-w-4.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm p-0 text-text-tertiary hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover',
tags.length === 0 && 'border-dashed px-1.25',
visibleTags.length === 0 && 'border-dashed px-1.25',
)}
>
<span className="flex items-center justify-center gap-0.5 system-2xs-medium-uppercase">
<span aria-hidden className="i-ri-add-line size-3 shrink-0" />
{tags.length === 0 && t(($) => $['skillManagement.detail.addTag'])}
{visibleTags.length === 0 && t(($) => $['skillManagement.detail.addTag'])}
</span>
</ComboboxTrigger>
<ComboboxPortal>

View File

@@ -2,7 +2,7 @@
import type { SkillResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import type { UIEvent } from 'react'
import type { DragEvent, UIEvent } from 'react'
import {
AlertDialog,
AlertDialogActions,
@@ -14,6 +14,13 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
@@ -48,6 +55,7 @@ import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/console'
import { downloadBlob } from '@/utils/download'
import { formatFileSize } from '@/utils/format'
import { fetchSkillArchiveBlob } from './client'
import { SkillReferencesList, SkillReferencesListSkeleton } from './detail/skill-metadata'
import { getSkillErrorCode, getSkillErrorDetailString, normalizeSkillError } from './error'
@@ -413,6 +421,7 @@ function SkillCard({
const { t: tCommon } = useTranslation('common')
const { formatTimeFromNow } = useFormatTimeFromNow()
const queryClient = useQueryClient()
const router = useRouter()
const nameId = useId()
const descriptionId = useId()
const draftStatusId = useId()
@@ -446,9 +455,10 @@ function SkillCard({
},
},
{
onSuccess: () => {
onSuccess: (copiedSkill) => {
toast.success(t(($) => $['skillManagement.duplicateSuccess']))
invalidateSkillListQueries(queryClient)
router.push(`/skills/${copiedSkill.id}?rename=true`)
},
onError: () => {
toast.error(t(($) => $['skillManagement.duplicateFailed']))
@@ -844,12 +854,175 @@ function SkillGrid({ state }: SkillGridProps) {
)
}
function ImportSkillDialogForm({
importing,
onImport,
}: {
importing: boolean
onImport: (file: File) => void
}) {
const { t } = useTranslation('skill')
const { t: tCommon } = useTranslation('common')
const inputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
const descriptionId = useId()
const errorId = useId()
const [file, setFile] = useState<File>()
const [invalidFile, setInvalidFile] = useState(false)
const [dragging, setDragging] = useState(false)
const selectFiles = (files: File[]) => {
if (importing || files.length === 0) return
const [selectedFile] = files
if (files.length !== 1 || !selectedFile || !/\.(?:zip|skill)$/iu.test(selectedFile.name)) {
setFile(undefined)
setInvalidFile(true)
return
}
setFile(selectedFile)
setInvalidFile(false)
}
const hasFiles = (event: DragEvent<HTMLDivElement>) =>
Array.from(event.dataTransfer.types).includes('Files')
return (
<form
onSubmit={(event) => {
event.preventDefault()
if (file && !importing) onImport(file)
}}
>
<div className="flex items-center justify-between border-b border-divider-subtle px-5 py-4">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['skillManagement.importDialog.title'])}
</DialogTitle>
<DialogClose
disabled={importing}
render={
<IconButton aria-label={tCommon(($) => $['operation.close'])}>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
</div>
<div className="p-5">
<div
role="group"
aria-label={t(($) => $['skillManagement.importDialog.title'])}
className={cn(
'flex min-h-34 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-components-dropzone-border-alt bg-components-dropzone-bg-alt px-5 py-4',
dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
)}
onDragEnter={(event) => {
if (!hasFiles(event)) return
event.preventDefault()
event.stopPropagation()
if (importing) return
dragDepthRef.current += 1
setDragging(true)
}}
onDragOver={(event) => {
if (!hasFiles(event)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = importing ? 'none' : 'copy'
}}
onDragLeave={(event) => {
if (!hasFiles(event)) return
event.preventDefault()
event.stopPropagation()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragging(false)
}}
onDrop={(event) => {
if (!hasFiles(event)) return
event.preventDefault()
event.stopPropagation()
dragDepthRef.current = 0
setDragging(false)
selectFiles(Array.from(event.dataTransfer.files))
}}
>
<input
ref={inputRef}
type="file"
accept=".zip,.skill,application/zip"
className="hidden"
aria-label={t(($) => $['skillManagement.importDialog.browse'])}
aria-describedby={invalidFile ? `${descriptionId} ${errorId}` : descriptionId}
aria-invalid={invalidFile}
disabled={importing}
onChange={(event) => {
selectFiles(Array.from(event.currentTarget.files ?? []))
event.currentTarget.value = ''
}}
/>
<span aria-hidden className="i-ri-file-zip-line size-8 shrink-0 text-text-quaternary" />
{file ? (
<div className="flex w-full items-center justify-center gap-3">
<div className="min-w-0 text-center">
<p className="system-sm-medium wrap-anywhere text-text-secondary">{file.name}</p>
<p className="system-xs-regular text-text-tertiary">{formatFileSize(file.size)}</p>
</div>
<IconButton
aria-label={tCommon(($) => $['operation.remove'])}
disabled={importing}
onClick={() => setFile(undefined)}
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
</div>
) : (
<p className="text-center system-md-medium text-text-secondary">
<Trans
ns="skill"
i18nKey={($) => $['skillManagement.importDialog.dropzone']}
components={{
browse: (
<button
type="button"
disabled={importing}
aria-describedby={invalidFile ? `${descriptionId} ${errorId}` : descriptionId}
className="cursor-pointer rounded-sm text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed"
onClick={() => inputRef.current?.click()}
/>
),
}}
/>
</p>
)}
<DialogDescription
id={descriptionId}
className="text-center system-sm-regular text-text-tertiary"
>
{t(($) => $['skillManagement.importDialog.description'])}
</DialogDescription>
</div>
{invalidFile && (
<p id={errorId} role="alert" className="mt-2 system-xs-regular text-text-destructive">
{t(($) => $['skillManagement.importDialog.invalidFile'])}
</p>
)}
</div>
<div className="flex justify-end gap-2 border-t border-divider-subtle px-5 py-4">
<DialogClose disabled={importing} render={<Button />}>
{tCommon(($) => $['operation.cancel'])}
</DialogClose>
<Button type="submit" variant="primary" disabled={!file} loading={importing}>
{t(($) => $['skillManagement.import'])}
</Button>
</div>
</form>
)
}
export default function SkillsPage() {
const { t } = useTranslation('skill')
const router = useRouter()
const queryClient = useQueryClient()
const { canDelete, canEdit } = useSkillPermissions()
const importInputRef = useRef<HTMLInputElement>(null)
const [importOpen, setImportOpen] = useState(false)
const listViewportRef = useRef<HTMLDivElement>(null)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const [keyword] = useQueryState(skillQueryParamNames.keyword, skillKeywordQueryParser)
@@ -910,8 +1083,8 @@ export default function SkillsPage() {
)
}
const handleFileChange = (file: File | undefined) => {
if (!file || importMutation.isPending || createMutation.isPending) return
const handleImport = (file: File) => {
if (!canEdit || importMutation.isPending || createMutation.isPending) return
importMutation.mutate(
{
@@ -921,6 +1094,7 @@ export default function SkillsPage() {
},
{
onSuccess: (skill) => {
setImportOpen(false)
toast.success(t(($) => $['skillManagement.importSuccess']))
invalidateSkillListQueries(queryClient)
router.push(`/skills/${skill.id}`)
@@ -946,9 +1120,6 @@ export default function SkillsPage() {
}
toast.error(t(($) => $['skillManagement.importFailed']))
},
onSettled: () => {
if (importInputRef.current) importInputRef.current.value = ''
},
},
)
}
@@ -1010,7 +1181,7 @@ export default function SkillsPage() {
creating: createMutation.isPending,
importing: importMutation.isPending,
onCreate: handleCreate,
onImport: () => importInputRef.current?.click(),
onImport: () => setImportOpen(true),
}
: undefined,
}
@@ -1051,19 +1222,12 @@ export default function SkillsPage() {
</h1>
</div>
<div className="mt-3.5">
<input
ref={importInputRef}
type="file"
accept=".zip,.skill,application/zip"
className="hidden"
onChange={(event) => handleFileChange(event.currentTarget.files?.[0])}
/>
<SkillsToolbar
canEdit={canEdit}
creating={createMutation.isPending}
importing={importMutation.isPending}
onCreate={handleCreate}
onImport={() => importInputRef.current?.click()}
onImport={() => setImportOpen(true)}
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
</div>
@@ -1086,6 +1250,16 @@ export default function SkillsPage() {
</ScrollAreaScrollbar>
</ScrollArea>
</div>
<Dialog
open={importOpen}
onOpenChange={(open) => {
if (!importMutation.isPending) setImportOpen(open)
}}
>
<DialogContent className="p-0">
<ImportSkillDialogForm importing={importMutation.isPending} onImport={handleImport} />
</DialogContent>
</Dialog>
<SkillListTagManagementModal
show={showTagManagementModal}
onClose={() => setShowTagManagementModal(false)}

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "استخدم مفاتيح الأسهم لتغيير الحجم. اضغط مطولاً على Shift لخطوات أكبر. يصغّر Home أو Enter الحجم ليتسع للمحتوى.",
"resize.size": "العرض {{width}} بكسل، الارتفاع {{height}} بكسل",
"resize.width": "العرض {{width}} بكسل",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "الفوترة",
"settings.customEndpoint": "نقطة نهاية مخصصة",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "توجد بالفعل مهارة اسمها \"{{name}}\". أعد تسميته إلى SKILL.md وقم باستيراده مرة أخرى.",
"skillManagement.errors.workspaceLimit": "وصلت مساحة العمل الخاصة بك إلى الحد الأقصى وهو 500 مهارة. احذف مهارة لإضافة مهارة جديدة.",
"skillManagement.import": "استيراد",
"skillManagement.importDialog.browse": "تصفّح",
"skillManagement.importDialog.description": "يدعم حزم .zip و.skill التي تحتوي على SKILL.md.",
"skillManagement.importDialog.dropzone": "اسحب ملفًا وأفلته، أو <browse>تصفّح الملفات</browse>",
"skillManagement.importDialog.invalidFile": "اختر حزمة واحدة بصيغة .zip أو .skill.",
"skillManagement.importDialog.title": "استيراد مهارة",
"skillManagement.importFailed": "فشل استيراد المهارة.",
"skillManagement.importSuccess": "Skill مستورد.",
"skillManagement.listLabel": "مهارات مساحة العمل",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "Mit den Pfeiltasten die Größe ändern. Umschalt für größere Schritte halten. Pos1 oder Eingabe verkleinert auf die für den Inhalt benötigte Größe.",
"resize.size": "{{width}} Pixel breit, {{height}} Pixel hoch",
"resize.width": "Breite: {{width}} Pixel",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Abrechnung",
"settings.customEndpoint": "Benutzerdefinierter Endpunkt",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "Ein Skill mit dem Namen „{{name}}“ ist bereits vorhanden. Benennen Sie es in SKILL.md um und importieren Sie es erneut.",
"skillManagement.errors.workspaceLimit": "Ihr Arbeitsbereich hat die Grenze von 500 Fertigkeiten erreicht. Löschen Sie eine Fertigkeit, um eine neue hinzuzufügen.",
"skillManagement.import": "Importieren",
"skillManagement.importDialog.browse": "Durchsuchen",
"skillManagement.importDialog.description": "Unterstützt .zip- und .skill-Pakete, die SKILL.md enthalten.",
"skillManagement.importDialog.dropzone": "Datei hierher ziehen oder <browse>durchsuchen</browse>",
"skillManagement.importDialog.invalidFile": "Wählen Sie ein .zip- oder .skill-Paket aus.",
"skillManagement.importDialog.title": "Skill importieren",
"skillManagement.importFailed": "Der Skill konnte nicht importiert werden.",
"skillManagement.importSuccess": "Skill importiert.",
"skillManagement.listLabel": "Fähigkeiten am Arbeitsplatz",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "Use arrow keys to resize. Hold Shift for larger steps. Home or Enter shrinks to fit the contents.",
"resize.size": "{{width}} pixels wide, {{height}} pixels high",
"resize.width": "Width: {{width}} pixels",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Creates workspace machine credentials for automation tasks and external systems, used only to discover and run specified applications via Difyctl.",
"settings.agentStrategy": "Agent Strategy",
"settings.billing": "Billing",
"settings.customEndpoint": "Custom Endpoint",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "A skill named \"{{name}}\" already exists. Rename it in SKILL.md and import again.",
"skillManagement.errors.workspaceLimit": "Your workspace has reached the limit of 500 skills. Delete a skill to add a new one.",
"skillManagement.import": "Import",
"skillManagement.importDialog.browse": "Browse",
"skillManagement.importDialog.description": "Supports .zip and .skill packages that contain SKILL.md.",
"skillManagement.importDialog.dropzone": "Drag and drop file, or <browse>Browse</browse>",
"skillManagement.importDialog.invalidFile": "Choose one .zip or .skill package.",
"skillManagement.importDialog.title": "Import skill",
"skillManagement.importFailed": "Failed to import skill.",
"skillManagement.importSuccess": "Skill imported.",
"skillManagement.listLabel": "Workspace skills",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "Usa las flechas para cambiar el tamaño. Mantén Mayús para pasos mayores. Inicio o Intro reduce el tamaño para ajustarlo al contenido.",
"resize.size": "{{width}} píxeles de ancho, {{height}} píxeles de alto",
"resize.width": "Ancho: {{width}} píxeles",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Facturación",
"settings.customEndpoint": "Endpoint personalizado",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "Ya existe una habilidad denominada \"{{name}}\". Cambie el nombre a SKILL.md e impórtelo nuevamente.",
"skillManagement.errors.workspaceLimit": "Tu espacio de trabajo ha alcanzado el límite de 500 habilidades. Elimina una habilidad para agregar una nueva.",
"skillManagement.import": "Importar",
"skillManagement.importDialog.browse": "Examinar",
"skillManagement.importDialog.description": "Se admiten paquetes .zip y .skill que contengan SKILL.md.",
"skillManagement.importDialog.dropzone": "Arrastra y suelta un archivo o <browse>examina los archivos</browse>",
"skillManagement.importDialog.invalidFile": "Selecciona un paquete .zip o .skill.",
"skillManagement.importDialog.title": "Importar habilidad",
"skillManagement.importFailed": "No se pudo importar la habilidad.",
"skillManagement.importSuccess": "Skill importado.",
"skillManagement.listLabel": "Habilidades en el espacio de trabajo",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "با کلیدهای جهت اندازه را تغییر دهید. برای گام‌های بزرگ‌تر Shift را نگه دارید. Home یا Enter اندازه را تا حد لازم برای نمایش محتوا کوچک می‌کند.",
"resize.size": "عرض {{width}} پیکسل، ارتفاع {{height}} پیکسل",
"resize.width": "عرض: {{width}} پیکسل",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "صورتحساب",
"settings.customEndpoint": "نقطه پایانی سفارشی",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "مهارتی به نام \"{{name}}\" از قبل وجود دارد. نام آن را در SKILL.md تغییر دهید و دوباره وارد کنید.",
"skillManagement.errors.workspaceLimit": "فضای کاری شما به حداکثر 500 مهارت رسیده است. برای افزودن مهارت جدید، مهارت را حذف کنید.",
"skillManagement.import": "وارد کردن",
"skillManagement.importDialog.browse": "مرور",
"skillManagement.importDialog.description": "بسته‌های .zip و .skill حاوی SKILL.md پشتیبانی می‌شوند.",
"skillManagement.importDialog.dropzone": "فایل را بکشید و رها کنید یا <browse>مرور کنید</browse>",
"skillManagement.importDialog.invalidFile": "یک بستهٔ .zip یا .skill انتخاب کنید.",
"skillManagement.importDialog.title": "وارد کردن مهارت",
"skillManagement.importFailed": "وارد کردن مهارت انجام نشد.",
"skillManagement.importSuccess": "Skill وارد شده است.",
"skillManagement.listLabel": "مهارت های فضای کاری",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "Utilisez les flèches pour redimensionner. Maintenez Maj pour de plus grands pas. Début ou Entrée réduit la taille au minimum nécessaire au contenu.",
"resize.size": "{{width}} pixels de large, {{height}} pixels de haut",
"resize.width": "Largeur : {{width}} pixels",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Facturation",
"settings.customEndpoint": "Point de terminaison personnalisé",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "Une compétence nommée \"{{name}}\" existe déjà. Renommez-le en SKILL.md et importez-le à nouveau.",
"skillManagement.errors.workspaceLimit": "Votre espace de travail a atteint la limite de 500 compétences. Supprimez une compétence pour en ajouter une nouvelle.",
"skillManagement.import": "Importer",
"skillManagement.importDialog.browse": "Parcourir",
"skillManagement.importDialog.description": "Les paquets .zip et .skill contenant SKILL.md sont pris en charge.",
"skillManagement.importDialog.dropzone": "Glissez-déposez un fichier ou <browse>parcourez les fichiers</browse>",
"skillManagement.importDialog.invalidFile": "Choisissez un paquet .zip ou .skill.",
"skillManagement.importDialog.title": "Importer une compétence",
"skillManagement.importFailed": "Échec de l'importation de la compétence.",
"skillManagement.importSuccess": "Skill importé.",
"skillManagement.listLabel": "Compétences en espace de travail",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "आकार बदलने के लिए तीर कुंजियाँ दबाएँ। बड़े बदलाव के लिए Shift दबाए रखें। Home या Enter आकार को सामग्री समाने लायक न्यूनतम आकार तक घटाता है।",
"resize.size": "चौड़ाई {{width}} पिक्सेल, ऊँचाई {{height}} पिक्सेल",
"resize.width": "चौड़ाई: {{width}} पिक्सेल",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "बिलिंग",
"settings.customEndpoint": "कस्टम एंडपॉइंट",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "\"{{name}}\" नामक कौशल पहले से मौजूद है। इसे SKILL.md में नाम बदलें और फिर से आयात करें।",
"skillManagement.errors.workspaceLimit": "आपका कार्यक्षेत्र 500 कौशल की सीमा तक पहुंच गया है। नया जोड़ने के लिए एक कौशल हटाएँ।",
"skillManagement.import": "आयात करें",
"skillManagement.importDialog.browse": "ब्राउज़ करें",
"skillManagement.importDialog.description": "SKILL.md वाली .zip और .skill पैकेज फ़ाइलें समर्थित हैं।",
"skillManagement.importDialog.dropzone": "फ़ाइल खींचकर छोड़ें, या <browse>ब्राउज़ करें</browse>",
"skillManagement.importDialog.invalidFile": "एक .zip या .skill पैकेज चुनें।",
"skillManagement.importDialog.title": "स्किल इम्पोर्ट करें",
"skillManagement.importFailed": "कौशल आयात करने में विफल.",
"skillManagement.importSuccess": "Skill आयातित.",
"skillManagement.listLabel": "कार्यस्थल कौशल",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "Gunakan tombol panah untuk mengubah ukuran. Tahan Shift untuk langkah lebih besar. Home atau Enter memperkecil ukuran agar pas dengan konten.",
"resize.size": "Lebar {{width}} piksel, tinggi {{height}} piksel",
"resize.width": "Lebar: {{width}} piksel",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Penagihan",
"settings.customEndpoint": "Endpoint Kustom",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "Skill bernama \"{{name}}\" sudah ada. Ubah namanya menjadi SKILL.md dan impor lagi.",
"skillManagement.errors.workspaceLimit": "Ruang kerja Anda telah mencapai batas 500 keterampilan. Hapus keterampilan untuk menambahkan keterampilan baru.",
"skillManagement.import": "Impor",
"skillManagement.importDialog.browse": "Telusuri",
"skillManagement.importDialog.description": "Mendukung paket .zip dan .skill yang berisi SKILL.md.",
"skillManagement.importDialog.dropzone": "Seret dan lepas file, atau <browse>telusuri file</browse>",
"skillManagement.importDialog.invalidFile": "Pilih satu paket .zip atau .skill.",
"skillManagement.importDialog.title": "Impor keterampilan",
"skillManagement.importFailed": "Gagal mengimpor keterampilan.",
"skillManagement.importSuccess": "Skill diimpor.",
"skillManagement.listLabel": "Keterampilan ruang kerja",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "Usa le frecce per ridimensionare. Tieni premuto Maiusc per incrementi maggiori. Home o Invio riduce le dimensioni adattandole al contenuto.",
"resize.size": "{{width}} pixel di larghezza, {{height}} pixel di altezza",
"resize.width": "Larghezza: {{width}} pixel",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Fatturazione",
"settings.customEndpoint": "Endpoint personalizzato",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "Esiste già una competenza denominata \"{{name}}\". Rinominalo in SKILL.md e importalo di nuovo.",
"skillManagement.errors.workspaceLimit": "La tua area di lavoro ha raggiunto il limite di 500 competenze. Elimina una competenza per aggiungerne una nuova.",
"skillManagement.import": "Importa",
"skillManagement.importDialog.browse": "Sfoglia",
"skillManagement.importDialog.description": "Sono supportati i pacchetti .zip e .skill che contengono SKILL.md.",
"skillManagement.importDialog.dropzone": "Trascina e rilascia un file oppure <browse>sfoglia</browse>",
"skillManagement.importDialog.invalidFile": "Scegli un pacchetto .zip o .skill.",
"skillManagement.importDialog.title": "Importa competenza",
"skillManagement.importFailed": "Impossibile importare la competenza.",
"skillManagement.importSuccess": "Skill importato.",
"skillManagement.listLabel": "Abilità nello spazio di lavoro",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "矢印キーでサイズを変更します。Shift を押しながら操作すると変更幅が大きくなります。Home または Enter で内容が収まる最小サイズに縮小します。",
"resize.size": "幅 {{width}} ピクセル、高さ {{height}} ピクセル",
"resize.width": "幅:{{width}}ピクセル",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "エージェント戦略",
"settings.billing": "請求",
"settings.customEndpoint": "カスタム API",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "「{{name}}」という名前のスキルはすでに存在します。SKILL.md で名前を変更してから、もう一度インポートしてください。",
"skillManagement.errors.workspaceLimit": "ワークスペースに追加できるスキルは 500 個までです。スキルを 1 つ削除してから、新しいスキルを追加してください。",
"skillManagement.import": "インポート",
"skillManagement.importDialog.browse": "参照",
"skillManagement.importDialog.description": "SKILL.md を含む .zip および .skill パッケージに対応しています。",
"skillManagement.importDialog.dropzone": "ファイルをドラッグ&ドロップするか、<browse>参照</browse>",
"skillManagement.importDialog.invalidFile": ".zip または .skill パッケージを1つ選択してください。",
"skillManagement.importDialog.title": "スキルをインポート",
"skillManagement.importFailed": "スキルのインポートに失敗しました。",
"skillManagement.importSuccess": "Skill がインポートされました。",
"skillManagement.listLabel": "ワークスペースのスキル",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "방향키로 크기를 조절합니다. Shift를 누르면 조절 폭이 커집니다. Home 또는 Enter로 내용에 맞는 최소 크기로 줄입니다.",
"resize.size": "너비 {{width}}픽셀, 높이 {{height}}픽셀",
"resize.width": "너비: {{width}}픽셀",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "청구",
"settings.customEndpoint": "사용자 지정 엔드포인트",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "\"{{name}}\"라는 스킬이 이미 존재합니다. SKILL.md에서 이름을 바꾸고 다시 가져옵니다.",
"skillManagement.errors.workspaceLimit": "작업 영역이 스킬 한도인 500개에 도달했습니다. 새로운 스킬을 추가하려면 스킬을 삭제하세요.",
"skillManagement.import": "가져오기",
"skillManagement.importDialog.browse": "찾아보기",
"skillManagement.importDialog.description": "SKILL.md가 포함된 .zip 및 .skill 패키지를 지원합니다.",
"skillManagement.importDialog.dropzone": "파일을 끌어다 놓거나 <browse>찾아보기</browse>",
"skillManagement.importDialog.invalidFile": ".zip 또는 .skill 패키지 하나를 선택하세요.",
"skillManagement.importDialog.title": "스킬 가져오기",
"skillManagement.importFailed": "스킬을 가져오지 못했습니다.",
"skillManagement.importSuccess": "Skill 가져왔습니다.",
"skillManagement.listLabel": "작업 공간 기술",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "ໃຊ້ປຸ່ມລູກສອນເພື່ອປັບຂະໜາດ. ກົດ Shift ຄ້າງໄວ້ເພື່ອປັບເທື່ອລະຫຼາຍຂຶ້ນ. Home ຫຼື Enter ຈະຫຍໍ້ໃຫ້ພໍດີກັບເນື້ອຫາ.",
"resize.size": "ກວ້າງ {{width}} ພິກເຊລ, ສູງ {{height}} ພິກເຊລ",
"resize.width": "ຄວາມກວ້າງ: {{width}} ພິກເຊລ",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "ກົນລະຍຸດຕົວແທນ",
"settings.billing": "ການຊຳລະເງິນ",
"settings.customEndpoint": "ຈຸດເຊື່ອມຕໍ່ແບບກຳນົດເອງ",

View File

@@ -189,6 +189,11 @@
"skillManagement.errors.nameConflict": "ທັກສະທີ່ມີຊື່ວ່າ \"{{name}}\" ມີຢູ່ແລ້ວ. ປ່ຽນຊື່ມັນຢູ່ໃນ SKILL.md ແລະນໍາເຂົ້າອີກເທື່ອຫນຶ່ງ.",
"skillManagement.errors.workspaceLimit": "ພື້ນທີ່ເຮັດວຽກຂອງທ່ານຮອດຂີດຈຳກັດຂອງ 500 ທັກສະແລ້ວ. ລຶບທັກສະເພື່ອເພີ່ມອັນໃໝ່.",
"skillManagement.import": "ນຳເຂົ້າ",
"skillManagement.importDialog.browse": "ເລືອກໄຟລ໌",
"skillManagement.importDialog.description": "ຮອງຮັບແພັກເກັດ .zip ແລະ .skill ທີ່ມີ SKILL.md.",
"skillManagement.importDialog.dropzone": "ລາກແລະວາງໄຟລ໌ ຫຼື <browse>ເລືອກໄຟລ໌</browse>",
"skillManagement.importDialog.invalidFile": "ເລືອກແພັກເກັດ .zip ຫຼື .skill ໜຶ່ງໄຟລ໌.",
"skillManagement.importDialog.title": "ນຳເຂົ້າທັກສະ",
"skillManagement.importFailed": "ລົ້ມເຫລວໃນການນໍາເຂົ້າທັກສະ.",
"skillManagement.importSuccess": "Skill ນໍາເຂົ້າ.",
"skillManagement.listLabel": "ທັກສະພື້ນທີ່ເຮັດວຽກ",

View File

@@ -545,6 +545,48 @@
"resize.nodeHelp": "Gebruik de pijltjestoetsen om het formaat te wijzigen. Houd Shift ingedrukt voor grotere stappen. Home of Enter verkleint het formaat tot de inhoud past.",
"resize.size": "{{width}} pixels breed, {{height}} pixels hoog",
"resize.width": "Breedte: {{width}} pixels",
"resourceAccessToken.accessibleResources": "Accessible Resources",
"resourceAccessToken.actions": "Actions",
"resourceAccessToken.app": "App",
"resourceAccessToken.appCount": "{{count}} App",
"resourceAccessToken.appsCount": "{{count}} Apps",
"resourceAccessToken.appsSection": "Apps",
"resourceAccessToken.copied": "Access token copied",
"resourceAccessToken.createButton": "Create new token",
"resourceAccessToken.createTitle": "Create access token",
"resourceAccessToken.created": "Access token created",
"resourceAccessToken.createdColumn": "Created",
"resourceAccessToken.createdToken": "Save your token",
"resourceAccessToken.createdTokenDescription": "Save this access token in a secure place—you won’t be able to view it again. Anyone who has it can use Difyctl to discover and access the resource authorized by this token. If you lose it, create a new token and revoke the old one.",
"resourceAccessToken.deleteDescription": "The Access token will be disabled immediately. Calls using this token will be rejected, which may cause the task that is using the access token to interrupt or fail. Once deleted, you will no longer be able to view or modify this Access token.",
"resourceAccessToken.deleteTitle": "Delete access token ?",
"resourceAccessToken.deleted": "Access token deleted",
"resourceAccessToken.done": "Done",
"resourceAccessToken.editTitle": "Edit access token",
"resourceAccessToken.empty": "No access tokens yet",
"resourceAccessToken.emptyDescription": "Create an access token for automated tasks and external systems to discover and access selected resource through Difyctl.",
"resourceAccessToken.knowledge": "Knowledge",
"resourceAccessToken.knowledgeBasesSection": "Knowledge bases",
"resourceAccessToken.knowledgeCount": "{{count}} Knowledge",
"resourceAccessToken.knowledgesCount": "{{count}} Knowledges",
"resourceAccessToken.name": "Name",
"resourceAccessToken.namePlaceholder": "Enter a name",
"resourceAccessToken.next": "Next",
"resourceAccessToken.noAccessibleResources": "No accessible resources",
"resourceAccessToken.noResourceSearchResults": "No matching resources",
"resourceAccessToken.noResources": "No API-enabled resources available",
"resourceAccessToken.previous": "Previous",
"resourceAccessToken.resources": "Resources",
"resourceAccessToken.searchPlaceholder": "Search tokens...",
"resourceAccessToken.searchResourcesPlaceholder": "Search apps or knowledge bases...",
"resourceAccessToken.tabAll": "All",
"resourceAccessToken.tabApps": "Apps",
"resourceAccessToken.tabKnowledgeBases": "Knowledge bases",
"resourceAccessToken.token": "Token",
"resourceAccessToken.total": "{{total}} total",
"resourceAccessToken.trackingId": "Tracking ID",
"settings.accessToken": "Access Tokens",
"settings.accessTokenDescription": "Create service API tokens that grant access only to selected apps and knowledge bases.",
"settings.agentStrategy": "Agent strategy",
"settings.billing": "Billing",
"settings.customEndpoint": "Aangepast eindpunt",

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