mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
feat: free AI tokens + home search/filter revamp (#10020)
* feat: add free Claude Opus tier with per-user token limit Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit move alert * Home AI Chat * wire home ai chat * auto send prompt * refactor: remove keyboard arrow-navigation from home list Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: replace home search bar with unified FilterSearchbar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: replace home quick tags with FilterSearchbar presets Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: add content filter to home FilterSearchbar with EE-gated content view - Clear the kind filter by deleting the key (was showing a 'kind: null' tag on All) - Remove the standalone Content button - Add a 'content' filter; when set, render the Ctrl-K content-search view (ContentSearchInner) which shows text-match snippets and its own EE warning Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: disable home AI chat and prompt to configure AI when no model Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * track cost instead of tokens * nit * fix: load copilot config on home so AI chat isn't wrongly gated Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Home page update * nits * example prompts * nit * feat: switch free AI tier to DeepSeek with daily cost budgets Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit * Move bottom buttons to HomeAIChat * [ee] feat: surface free AI tier state and make its metering abort-proof Makes the free Windmill AI tier legible to the user and closes an abuse hole. Backend: - AIConfig gains a response-only free_tier marker (skip_deserializing so a client can't store a forged one via edit_copilot_config). get_copilot_info keeps returning it once the grant is spent, so the client knows AI is off because the grant ran out, not because nothing was configured. - Per-user grant becomes one-time (migration drops the day key from ai_free_token_usage); the daily table stays as the instance kill-switch. - Reserve-then-reconcile metering (see EE commit) so a mid-stream disconnect can no longer dodge the usage report and get metered zero. Frontend: - copilotInfo carries freeTier; model settings show a "Free" pill and a usage meter that warns past 80%. - The home chat and the session chat show a dedicated "you've used your free Windmill AI, add your own API key" state instead of the generic "no provider configured" one. - A failed send re-fetches copilot_info so the exhausted state (and its banner) appears live, without a page reload. Bumps ee-repo-ref.txt to the matching EE commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: free AI usage meter reusing the context-usage gauge Show free-tier spend with the same gauge as context usage instead of a bespoke block: - Extract the meter+tooltip into a shared UsageMeter; ContextUsageIndicator uses it, and a new FreeTierUsageIndicator renders it from copilotInfo.freeTier. Placed in the session-chat toolbar and next to the home-chat model settings; the old meter block in the model-settings dropdown is removed (the "Free" pill stays). - Hide the context-usage bar while on the free tier so the free meter takes that slot. - Refresh copilotInfo after every free-tier turn (AIChatManager finally) so the meter advances live and the turn that exhausts the grant flips to the exhausted state, instead of both only updating on reload. Gated to active free-tier users, so it costs nothing for configured-key users. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: fix stale free-tier comments after DeepSeek/cost rework Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: always show context bar, replace free-tier meter with usage banner Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit * fix: atomic free-tier budget reservation (ee ref + sqlx) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: keep CLI/MCP and Hub buttons unblurred on AI chat hover Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add back arrow nav * nit * nit * fix: three review P1s in the home AI chat & search - AIChatManager: refreshFreeTierUsage now bails unless the global copilot state still belongs to the completing manager's workspace, so a warm session finishing after a workspace switch can't reload its (background) workspace over the active one's models/client/copilotWorkspace. - HomeAIChat: block submission until the copilot config is loaded AND enabled (new `canSend`), so a prompt submitted during the unknown-config window isn't handed to a session that never sends it and silently lost. The disabled overlay still gates on config-loaded to avoid a flash. - ItemsList: the content-search reload effect now depends on $workspaceStore so content results follow the active workspace instead of showing the previous one's. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [ee] fix: harden the three home-AI-chat/search P1s after deeper review Follow-up to the previous P1 commit; sharper review found the earlier guards insufficient: - refreshFreeTierUsage now compares against the most-recently-*requested* workspace (new copilotWorkspaceRequested in aiStore, set synchronously in loadCopilot), not the last-*resolved* one — otherwise a warm session finishing while a newer workspace's load is still in flight could win the monotonic token and restore its stale workspace over the one being loaded. - The content-search view is keyed by workspace ({#key $workspaceStore}) so a switch remounts ContentSearchInner; late in-flight responses from the previous workspace can no longer land in the new one's component. Backend (EE, via ee-repo-ref bump to 03ef0eb): the free-tier reservation now also prices the worst-case input cap (at the cache-miss rate), and enforce_free_tier_body rejects oversized prompts and pins n=1 — so an aborted large-prompt request can no longer dodge the input bill that reconciliation would otherwise charge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: exclude service accounts from the free AI tier Free-tier eligibility was keyed solely on authed.email. Workspace admins can create and impersonate arbitrary service accounts (synthetic *.sa.wm.dev identities), each of which would receive its own one-time grant — letting one tenant mint many grants and drain the instance-wide daily allowance. Skip the free-tier fallback for *.sa.wm.dev identities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: activate free AI tier when clearing a workspace provider edit_copilot_config returned AIConfig::default() when the saved workspace config had no providers and no instance config existed; the frontend applies that response immediately, disabling AI even though the free-tier key is available. A later get_copilot_info (on reload) returns the synthetic free-tier config, so clearing a provider behaved inconsistently until reload. Give this response path the same free-tier fallback as get_copilot_info. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: gate the home AI composer behind the global-AI dev flag The "Build with AI" composer starts a session and navigates to /sessions, which lives behind the same wm_dev_global_ai dev gate as the global AI chat. With the gate off (the default), /sessions renders only its gate message, SessionWrapper never mounts, and the queued prompt is silently dropped. Hide the home entry point behind isGlobalAiEnabled() so it isn't exposed before the sessions gate opens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [ee] chore: bump ee-repo-ref for deepseek-v4-flash price/model fix Points at the EE commit that pins deepseek-v4-flash and its real prices (pico-precision accounting). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [ee] fix: provable byte bound for the free-tier input cap (ee-repo-ref) Bumps ee-repo-ref to the EE commit that caps the raw request body byte length directly (token_count <= byte_count is provable), replacing the unsafe body.len()/2 token estimate that high-entropy prompts could beat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit isGlobalAiEnabled * empty commit * fix(frontend): address Codex review on free-tier / home filters - P1: home filters now sync from the URL reactively, so browser Back/Forward updates the chips, kind toggle and results (and clears keys dropped from the URL) instead of leaving them stale until the next filter edit. - Free-tier banner buttons drop deprecated Button props (size/color/border variant) for unifiedSize + a supported variant. - Condense refreshFreeTierUsage comments to a single race-condition constraint beside the guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(frontend): hide empty kind badge on draft-only scripts A draft-only script can carry an empty `kind`, which still isn't 'script' so the row rendered a blue badge whose only content was capitalize('') — an empty pill left of the "Draft only" badge. Guard the badge on a non-empty kind. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(frontend): animate home tree-view group expand/collapse Wrap each owner group's children in ResizeTransitionWrapper so height changes animate. A slide transition only animates the initial mount, but a freshly-opened owner fetches its rows and passes through a transient empty state before they land — the ResizeObserver animates that second growth too. Nested TreeViews inherit the wrapper's context and skip their own, so one observer per top-level owner animates the whole subtree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(frontend): FilterSearchbar boolean auto-set and string-filter presets - A default-false boolean filter has only one useful value, so selecting it sets true immediately instead of opening a true/false picker. A default-true boolean (e.g. "Include library scripts") still shows the picker, where false is the meaningful choice — expressed via a new optional `default` on the schema. - A plain string filter now surfaces any presets targeting it (`<tag>:<value>`) as suggestions once selected, integrated into menuItems so keyboard nav works — previously selecting e.g. "Owner" showed nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(frontend): home page toolbar and content-filter revamp - "New" create-menu button (scripts/flows/apps/…) replaces the old Content button; the search bar moves to the right of the toggle group. - Restore the content filter dropped in a merge: a `content` searchbar filter swaps the list for the full-text ContentSearchInner view (EE), aligned flush with -mx-2. - Move the owner/group and label chips off the page into FilterSearchbar presets; ownerFilter/labelFilter now derive from the searchbar keys (data layer unchanged). - Move the list controls (select / tree view / expand-all / sort) inline into the top row between the toggle group and search bar; add margin above the list. - Beta tag on the home AI chat; a bit more bottom margin under it; tighten the gap between the admin/tutorial banners and the list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ai): pass the request body to the free-tier reservation Thread the prompt body into resolve_free_tier_credentials so the free tier can size its upfront reservation from the actual request length instead of a fixed worst case (EE c2e248b), fixing normal chats being rejected as "too large". Updates the OSS stub signature and bumps ee-repo-ref. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(frontend): gate home Create/Import menu on edit permissions The relocated CreateActionsMenu rendered unconditionally, so operators and users in workspaces protected from direct deployment saw create/import actions they can't use. Restore the original gate (!operator && showEditButtons, the latter from NoDirectDeployAlert). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(frontend): address Codex review on filter searchbar - P1: the boolean shortcut now goes through the same tag-insertion path as the normal branch, so it removes the typed search segment instead of leaving it as a stray free-text (_default_) term. - Mark the Runs `show_future_jobs` filter default: true so selecting it opens the picker (false is the meaningful choice) rather than being a no-op. - Home owner/label presets now emit the canonical `key:\ value` form so the applied-preset check matches after a reparse and can't re-offer a duplicate; update the suggestion extraction to strip the leading separator. - Replace deprecated Button props (size/spacingSize/color) on the relocated list controls with unifiedSize. - Fix stale comments: UsageMeter no longer claims a free-tier consumer; the home filter schema comment describes presets, not the removed ListFilters/label badges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(frontend): boolean filter shortcut sets value canonically The round-1 shortcut baked `true` into the tag text, which merged into a following tag (e.g. `archived:\ truekind:\ flow`). Instead remove the typed segment, set the value, and reparse so the text is rebuilt canonically — no lingering free-text and no merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(ai): restate free-tier caller identity contract in the OSS stub; bump ee-repo-ref Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(frontend): keep flanking tags separate when boolean shortcut drops a segment Joining `before`/`after` directly fused the tags a removed mid-segment sat between (e.g. `kind:\ flowsummary:\ bar`). Join with a space; reparse then canonicalizes. Also trims the comment to the essential constraint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(ai): update sqlx cache for free-tier daily-day queries; bump ee-repo-ref The reserve/reconcile daily-usage queries now bind the reservation day (EE change); refresh their offline query cache and point ee-repo-ref at the EE commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ai): activate free tier when instance ai_config has no provider An instance ai_config row won precedence just by existing, so an empty {} (valid via global settings / declarative config) suppressed the free-tier fallback and left AI disabled — even though build_copilot_settings_state already treats it as unconfigured. Apply the same has_providers() check to the instance config in the proxy and edit_copilot_config paths. Also refresh the sqlx cache for the reservation ceiling change and bump ee-repo-ref. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(frontend): migrate legacy Home filter URLs to the searchbar keys The old Home UI stored free-text in `search`, owner scope in `filter`, and could write `kind=all`; the generic searchbar sync uses `_default_`, `owner`, and a kind enum without `all`. Rewrite those params once before the sync reads the URL so shared/bookmarked links restore, and drop `kind=all` which would otherwise wedge later filter edits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ai): empty instance config in get_copilot_info; label user-disabled Home AI - get_copilot_info returned any existing instance ai_config row before the free-tier fallback, so an empty {} disabled AI in the copilot-info UI even though the proxy now serves the free tier. Apply the same has_providers() gate here. - The Home chat overlay said "No AI provider is configured" when the user had disabled AI in account settings (providers still present). Distinguish that state ("Windmill AI is disabled in your account settings") as the docked chat does, and drop the misleading workspace-config button in that case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(ai): drop redundant proxy service-account check; trim TreeView comment The service-account exclusion now lives in the free-tier helper, so the proxy calls it directly. Also condense the tree-view resize-transition comment to the essential reason. Bumps ee-repo-ref. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(frontend): the Home content filter is not EE-gated ContentSearchInner loads the workspace's scripts/flows/apps/resources and matches their contents client-side, so it works on any instance. Drop the misleading "(EE)" from the filter label and the "EE indexer / off-EE fallback" comments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(ee): bump ee-repo-ref for free-tier pricing + exhaustion fixes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSqa1iRxn9GUE9fegT7bDS * fix(frontend): show disabled Home AI overlay statically, not on hover The disabled-state overlay (reason + configure/add-key action) was opacity-0 and pointer-events-none until group-hover, so keyboard and touch users saw an inert composer with no visible remedy. Render it and the composer blur statically when disabled instead. Also bumps ee-repo-ref for the trimmed free-tier comments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSqa1iRxn9GUE9fegT7bDS * fix(frontend): give account-disabled Home AI overlay a recovery action The account-disabled branch showed a reason but hid every action, on the mistaken premise that account settings has no linkable route. It opens from the #user-settings hash (the same one the sidebar Account menu uses), so link there. Bumps ee-repo-ref for the free-tier fixes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSqa1iRxn9GUE9fegT7bDS * fix(frontend): gate Home AI composer for operators; a11y and filter-sync fixes - Home composer now uses prefersSessionHandoff($userStore?.operator) instead of isGlobalAiEnabled(): operators reached this route and could submit a prompt into a /sessions page that refuses them, silently dropping it. Also drops the leftover empty header spacer div above the chat. - HomeAIChat: mark the blurred/disabled subtrees inert so keyboard users can't tab into the unreadable textarea (pointer-events-none didn't stop Tab). - ItemsList: keep the role-dependent searchbar keys (include_library, only_user_folders) in the schema unconditionally and toggle `hidden` instead, so useUrlSyncedFilterInstance (which snapshots the key set once) still URL-syncs a key that first appears after a workspace switch. - Bumps ee-repo-ref for the indexer non-parquet build fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSqa1iRxn9GUE9fegT7bDS * fix(frontend): keep CLI/MCP connect row for operators; trim filter comment The previous commit gated all of HomeAIChat behind the operator/session check, which also removed the AI-independent CLI/MCP "Connect workspace" drawer that operators (and the sessions-beta opt-out) had on main. Render HomeAIChat for the same audience as before (isGlobalAiEnabled) and gate only the composer (title, input, examples, overlay) on operator status inside the component; the connect row always shows. Also trims the role-dependent filter-schema comment to the <=4 line rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSqa1iRxn9GUE9fegT7bDS * fix(frontend): reconnect Home keyboard navigation to the unified searchbar The searchbar migration replaced the <input id="home-search-input"> the ItemsList keyboard handler keys off, so Arrow/Enter no longer drove the results list. Thread an `id` down to the searchbar's contenteditable (via TaggedTextInput/FilterSearchbar `inputId`) so the handler and the workspace-switch focus restoration find it again; read the caret through the Selection API instead of an <input>'s selectionStart/End; and stand the list's arrows down while the searchbar's suggestion dropdown is open (tracked via onDropdownVisibleChange). In free-text mode the searchbar no longer opens its dropdown on a bare arrow key, so an empty box passes Arrow/Enter to the list as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSqa1iRxn9GUE9fegT7bDS * fix(frontend): stop searchbar Enter inserting a newline; idle typewriter for operators - TaggedTextInput is a single-line filter input, so Enter now preventDefaults the contenteditable's newline insertion (surrounding suggestion-select / list-open handlers still run on bubble). Previously Enter with no row highlighted dropped a literal \n into the query. - HomeAIChat's placeholder typewriter effect now runs only while the composer is shown, so it no longer loops forever driving an unrendered input for operators. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSqa1iRxn9GUE9fegT7bDS * chore: update ee-repo-ref to f2a31156ac08ecb02d89dbc66d72be58e9c877ff This commit updates the EE repository reference after PR #652 was merged in windmill-ee-private. Previous ee-repo-ref: e59b96a2eea5d1110b40c842f17b337ab051bdd3 New ee-repo-ref: f2a31156ac08ecb02d89dbc66d72be58e9c877ff Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
windmill-internal-app[bot]
parent
1462f17643
commit
716ce2ece0
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT cost_nanos FROM ai_free_token_usage WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cost_nanos",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ($2::date, $1::bigint, now())\n ON CONFLICT (day) DO UPDATE\n SET cost_nanos = ai_free_token_daily_usage.cost_nanos + $1::bigint,\n updated_at = now()\n RETURNING cost_nanos",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cost_nanos",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Date"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "44b9400fed2082c0df9d57f940923f37b8d452ea8e199ccc207da238368f1996"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ($2::date, GREATEST(0, $1::bigint), now())\n ON CONFLICT (day) DO UPDATE\n SET cost_nanos = GREATEST(0, ai_free_token_daily_usage.cost_nanos + $1::bigint),\n updated_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Date"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "acf4a5f4b67ebd06157819677fee6be4cc3adc6aa859c3dbaa57fa50b63741cf"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_free_token_usage (email, cost_nanos, updated_at)\n VALUES ($1, $2::bigint, now())\n ON CONFLICT (email) DO UPDATE\n SET cost_nanos = ai_free_token_usage.cost_nanos + $2::bigint,\n updated_at = now()\n RETURNING cost_nanos",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cost_nanos",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c29d815cc38493f17950f12e9e5fe34b6c4ed1b2a49ed2b61dc9fb494026e956"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_free_token_usage (email, cost_nanos, updated_at)\n VALUES ($1, GREATEST(0, $2::bigint), now())\n ON CONFLICT (email) DO UPDATE\n SET cost_nanos = GREATEST(0, ai_free_token_usage.cost_nanos + $2::bigint),\n updated_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
df60763d1f243b0048dfc3fe700bc026b257bea8
|
||||
f2a31156ac08ecb02d89dbc66d72be58e9c877ff
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE ai_free_token_daily_usage;
|
||||
DROP TABLE ai_free_token_usage;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- One-time grant of the Windmill-provided free AI tier, measured as cost in nano-dollars
|
||||
-- (1e-9 USD) rather than raw tokens — a prompt-cache hit costs a fraction of a fresh input
|
||||
-- token, so a token count wildly overstates the real bill. The grant never resets: once
|
||||
-- spent, the user must bring their own API key. Keyed by normalized email so the allowance
|
||||
-- is shared across a user's workspaces (and is resistant to +tag / gmail-dot aliasing).
|
||||
CREATE TABLE ai_free_token_usage (
|
||||
email VARCHAR(255) PRIMARY KEY,
|
||||
cost_nanos BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Instance-wide daily cost ceiling (nano-dollars) for the free tier — a kill-switch
|
||||
-- independent of the per-user grant, bounding the blast radius of a bad day. One row per
|
||||
-- UTC day.
|
||||
CREATE TABLE ai_free_token_daily_usage (
|
||||
day DATE PRIMARY KEY,
|
||||
cost_nanos BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -38,6 +38,8 @@ account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), clien
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char)
|
||||
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
|
||||
ai_free_token_daily_usage: day(date), cost_nanos(bigint), updated_at(ts)
|
||||
ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts)
|
||||
ai_token_usage: workspace_id(char), day(date), email(char), provider(char), model(char), session_id(char), input_tokens(bigint), cache_read_tokens(bigint), cache_write_tokens(bigint), output_tokens(bigint), reported_cost_nano_usd(bigint), requests(bigint), updated_at(ts)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(bool), workspace_id(text), acknowledged_workspace(bool), resource(text)
|
||||
|
||||
@@ -27256,11 +27256,29 @@ components:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 2000000
|
||||
free_tier:
|
||||
$ref: "#/components/schemas/FreeTierInfo"
|
||||
model_pricing:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/ModelPriceOverride"
|
||||
|
||||
FreeTierInfo:
|
||||
type: object
|
||||
description: >-
|
||||
Read-only. Present when the workspace has no AI provider of its own and is running
|
||||
on Windmill's free tier. Ignored on write.
|
||||
properties:
|
||||
exhausted:
|
||||
type: boolean
|
||||
description: The one-time grant is spent; no provider is served and the user must add their own API key.
|
||||
used_ratio:
|
||||
type: number
|
||||
description: Fraction of the grant consumed, 0 to 1.
|
||||
required:
|
||||
- exhausted
|
||||
- used_ratio
|
||||
|
||||
ModelPriceOverride:
|
||||
type: object
|
||||
description: negotiated rates in USD per million tokens, keyed `provider:model`
|
||||
|
||||
+182
-97
@@ -409,6 +409,19 @@ impl ExpiringProviderCredentials {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set on the copilot config when the workspace has no AI provider of its own and is
|
||||
/// running on Windmill's free tier, so the client can label the lent model as free, warn
|
||||
/// before the grant runs out, and tell the user to add their own key once it has — rather
|
||||
/// than showing the same "no provider configured" state a never-configured workspace gets.
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||
pub struct FreeTierInfo {
|
||||
/// The grant is spent: no provider is served and the user must bring their own key.
|
||||
pub exhausted: bool,
|
||||
/// Fraction of the grant consumed, 0.0..=1.0. A ratio, not a dollar amount — the
|
||||
/// pricing model stays server-side.
|
||||
pub used_ratio: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct AIConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -423,6 +436,11 @@ pub struct AIConfig {
|
||||
pub custom_prompts: Option<HashMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens_per_model: Option<HashMap<String, i32>>,
|
||||
/// Response-only: this same struct is the request body for saving a workspace's AI
|
||||
/// config, and `skip_deserializing` is what stops a client from storing a forged
|
||||
/// free-tier marker. Only the server sets it, per-request.
|
||||
#[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
|
||||
pub free_tier: Option<FreeTierInfo>,
|
||||
/// Per-model price overrides, keyed `provider:model` like `max_tokens_per_model`.
|
||||
/// Only models whose rates differ from the built-in table are stored.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1013,83 +1031,119 @@ async fn proxy(
|
||||
check_scopes(&authed, || format!("resources:read:{}", resource_path))?;
|
||||
}
|
||||
|
||||
let mut credentials = match workspace_cache {
|
||||
Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => {
|
||||
request_cache.credentials
|
||||
}
|
||||
_ => {
|
||||
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
|
||||
if let Some(resource_path) = forced_resource_path {
|
||||
// forced resource path
|
||||
(resource_path, false, w_id.clone(), None)
|
||||
} else {
|
||||
let workspace_ai_config = sqlx::query_scalar!(
|
||||
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
// Set when serving the request through Windmill's free AI tier (the lent key). Holds
|
||||
// the per-user concurrency lock and drives response metering.
|
||||
let mut free_lease: Option<crate::ai_free_tier_oss::FreeTierLease> = None;
|
||||
let mut credentials = 'cred: {
|
||||
match workspace_cache {
|
||||
Some(request_cache)
|
||||
if !request_cache.is_expired() && forced_resource_path.is_none() =>
|
||||
{
|
||||
request_cache.credentials
|
||||
}
|
||||
_ => {
|
||||
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
|
||||
if let Some(resource_path) = forced_resource_path {
|
||||
// forced resource path
|
||||
(resource_path, false, w_id.clone(), None)
|
||||
} else {
|
||||
let workspace_ai_config = sqlx::query_scalar!(
|
||||
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
|
||||
let ws_has_config = workspace_ai_config
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
|
||||
.is_some_and(|config| config.has_providers());
|
||||
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
|
||||
let ws_has_config = workspace_ai_config
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
|
||||
.is_some_and(|config| config.has_providers());
|
||||
|
||||
if ws_has_config {
|
||||
(workspace_ai_config.unwrap(), w_id.clone(), None)
|
||||
} else {
|
||||
let instance_config = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = 'ai_config'"
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if ws_has_config {
|
||||
(workspace_ai_config.unwrap(), w_id.clone(), None)
|
||||
} else {
|
||||
let instance_config = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = 'ai_config'"
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
match instance_config {
|
||||
Some(config) => (
|
||||
config,
|
||||
"admins".to_string(),
|
||||
Some(current_instance_ai_config_revision()),
|
||||
),
|
||||
None => {
|
||||
return Err(Error::internal_err(
|
||||
"AI resource not configured".to_string(),
|
||||
));
|
||||
let instance_has_config =
|
||||
instance_config.as_ref().is_some_and(|v| {
|
||||
serde_json::from_value::<AIConfig>(v.clone())
|
||||
.ok()
|
||||
.is_some_and(|c| c.has_providers())
|
||||
});
|
||||
match instance_config {
|
||||
// An instance `ai_config` row with no usable provider (e.g. `{}`
|
||||
// or `{"providers":{}}`) is treated as unconfigured, exactly as
|
||||
// build_copilot_settings_state does — otherwise its mere presence
|
||||
// would suppress the free-tier fallback below.
|
||||
Some(config) if instance_has_config => (
|
||||
config,
|
||||
"admins".to_string(),
|
||||
Some(current_instance_ai_config_revision()),
|
||||
),
|
||||
_ => {
|
||||
// Nothing configured: fall back to Windmill's free AI tier
|
||||
// (EE-only) if a lent key is set and both the user's
|
||||
// one-time grant and the instance's daily cap have room.
|
||||
// Errors once the grant is spent, the day is capped, or the
|
||||
// user already has a request in flight; None otherwise.
|
||||
// Ineligible identities (e.g. service accounts) are refused
|
||||
// inside the helper, so every path treats them alike.
|
||||
let free =
|
||||
crate::ai_free_tier_oss::resolve_free_tier_credentials(
|
||||
&provider,
|
||||
&db,
|
||||
&ai_path,
|
||||
&authed.email,
|
||||
&body,
|
||||
)
|
||||
.await?;
|
||||
if let Some((free_credentials, lease)) = free {
|
||||
free_lease = Some(lease);
|
||||
break 'cred free_credentials;
|
||||
}
|
||||
return Err(Error::internal_err(
|
||||
"AI resource not configured".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
|
||||
let provider_config = ai_config
|
||||
.providers
|
||||
.as_mut()
|
||||
.and_then(|providers| providers.remove(&provider))
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!("Provider {:?} not configured", provider))
|
||||
})?;
|
||||
|
||||
if provider_config.resource_path.is_empty() {
|
||||
return Err(Error::BadRequest("Resource path is empty".to_string()));
|
||||
}
|
||||
|
||||
(
|
||||
provider_config.resource_path,
|
||||
true,
|
||||
resource_workspace,
|
||||
instance_ai_config_revision,
|
||||
)
|
||||
};
|
||||
|
||||
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
|
||||
let provider_config = ai_config
|
||||
.providers
|
||||
.as_mut()
|
||||
.and_then(|providers| providers.remove(&provider))
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!("Provider {:?} not configured", provider))
|
||||
})?;
|
||||
|
||||
if provider_config.resource_path.is_empty() {
|
||||
return Err(Error::BadRequest("Resource path is empty".to_string()));
|
||||
}
|
||||
|
||||
(
|
||||
provider_config.resource_path,
|
||||
true,
|
||||
resource_workspace,
|
||||
instance_ai_config_revision,
|
||||
)
|
||||
};
|
||||
|
||||
// For user-specified resources, fetch through an RLS-scoped
|
||||
// connection so PostgreSQL row-level security enforces the same
|
||||
// folder/group boundaries as the regular resource API. For the
|
||||
// workspace/instance ai_config path, the resource_path was already
|
||||
// validated by an admin/devops user when configuring the workspace,
|
||||
// so the raw pool is used.
|
||||
let resource = if is_user_specified_resource {
|
||||
// For user-specified resources, fetch through an RLS-scoped
|
||||
// connection so PostgreSQL row-level security enforces the same
|
||||
// folder/group boundaries as the regular resource API. For the
|
||||
// workspace/instance ai_config path, the resource_path was already
|
||||
// validated by an admin/devops user when configuring the workspace,
|
||||
// so the raw pool is used.
|
||||
let resource = if is_user_specified_resource {
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
let res = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
|
||||
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
|
||||
@@ -1112,38 +1166,45 @@ async fn proxy(
|
||||
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
|
||||
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", resource_path)))?;
|
||||
|
||||
let resource = serde_json::from_str::<AIResource>(resource.0.get())
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
let resource = serde_json::from_str::<AIResource>(resource.0.get())
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
|
||||
// Enforce RLS on $var: resolution when the resource path was
|
||||
// user-specified (X-Resource-Path header) so users can only read
|
||||
// variables they have permission to access.
|
||||
let enforce_authed = if is_user_specified_resource {
|
||||
Some(&authed)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let credentials = resolve_provider_credentials(
|
||||
&provider,
|
||||
&db,
|
||||
&resource_workspace,
|
||||
resource,
|
||||
enforce_authed,
|
||||
)
|
||||
.await?;
|
||||
if save_to_cache {
|
||||
AI_REQUEST_CACHE.insert(
|
||||
(w_id.clone(), provider.clone()),
|
||||
ExpiringProviderCredentials::new(
|
||||
credentials.clone(),
|
||||
instance_ai_config_revision,
|
||||
),
|
||||
);
|
||||
// Enforce RLS on $var: resolution when the resource path was
|
||||
// user-specified (X-Resource-Path header) so users can only read
|
||||
// variables they have permission to access.
|
||||
let enforce_authed = if is_user_specified_resource {
|
||||
Some(&authed)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let credentials = resolve_provider_credentials(
|
||||
&provider,
|
||||
&db,
|
||||
&resource_workspace,
|
||||
resource,
|
||||
enforce_authed,
|
||||
)
|
||||
.await?;
|
||||
if save_to_cache {
|
||||
AI_REQUEST_CACHE.insert(
|
||||
(w_id.clone(), provider.clone()),
|
||||
ExpiringProviderCredentials::new(
|
||||
credentials.clone(),
|
||||
instance_ai_config_revision,
|
||||
),
|
||||
);
|
||||
}
|
||||
credentials
|
||||
}
|
||||
credentials
|
||||
}
|
||||
};
|
||||
|
||||
// Free tier: pin the model and clamp max_tokens server-side before forwarding,
|
||||
// since the request body is otherwise client-controlled.
|
||||
if free_lease.is_some() {
|
||||
body = crate::ai_free_tier_oss::enforce_free_tier_body(&body)?;
|
||||
}
|
||||
|
||||
if let Some(fim_transform) =
|
||||
maybe_transform_fim_request(&provider, &ai_path, &credentials.base_url, &body)?
|
||||
{
|
||||
@@ -1291,8 +1352,32 @@ async fn proxy(
|
||||
|
||||
let status_code = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let is_sse = is_sse_response(&headers);
|
||||
|
||||
// Free tier: reconcile the cost reserved up-front against what the response actually
|
||||
// used, holding the per-user lock (via the lease) until it is recorded. The chat
|
||||
// streams (SSE), where the usage report only arrives in the final chunk; the
|
||||
// non-streaming JSON path is handled for completeness.
|
||||
if let Some(lease) = free_lease {
|
||||
let body = if is_sse {
|
||||
axum::body::Body::from_stream(inject_keepalives(
|
||||
Box::pin(crate::ai_free_tier_oss::meter_usage(
|
||||
response.bytes_stream(),
|
||||
db.clone(),
|
||||
lease,
|
||||
)),
|
||||
Duration::from_secs(KEEPALIVE_INTERVAL_SECS),
|
||||
))
|
||||
} else {
|
||||
let bytes = response.bytes().await.map_err(to_anyhow)?;
|
||||
crate::ai_free_tier_oss::record_json_usage(db.clone(), lease, &bytes);
|
||||
axum::body::Body::from(bytes)
|
||||
};
|
||||
return Ok((status_code, headers, body));
|
||||
}
|
||||
|
||||
let stream = response.bytes_stream();
|
||||
let body = if is_sse_response(&headers) {
|
||||
let body = if is_sse {
|
||||
axum::body::Body::from_stream(inject_keepalives(
|
||||
stream,
|
||||
Duration::from_secs(KEEPALIVE_INTERVAL_SECS),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#[cfg(feature = "private")]
|
||||
#[allow(unused)]
|
||||
pub use crate::ai_free_tier_ee::*;
|
||||
|
||||
// Open-source build: Windmill's free AI tier does not exist. These stubs make the
|
||||
// callers in `ai.rs` / `workspaces.rs` compile while disabling the feature entirely —
|
||||
// `resolve_free_tier_credentials` never opts in, so the proxy falls through to its
|
||||
// normal "AI resource not configured" path and the copilot stays hidden.
|
||||
//
|
||||
// Caller contract (enforced by the private impl, restated here for parity): the `email`
|
||||
// passed to `resolve_free_tier_credentials` / `free_tier_copilot_config` MUST be the
|
||||
// authenticated caller's own identity (an `ApiAuthed` email), never a client-supplied one —
|
||||
// it selects whose lent-key grant is spent and whose usage is read.
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
use crate::ai::AIConfig;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use crate::db::DB;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use axum::body::Bytes;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_ai::ai_providers::AIProvider;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_ai::credentials::ProviderCredentials;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_common::error::Result;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub struct FreeTierLease;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn resolve_free_tier_credentials(
|
||||
_provider: &AIProvider,
|
||||
_db: &DB,
|
||||
_ai_path: &str,
|
||||
_email: &str,
|
||||
_body: &Bytes,
|
||||
) -> Result<Option<(ProviderCredentials, FreeTierLease)>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn enforce_free_tier_body(body: &Bytes) -> Result<Bytes> {
|
||||
Ok(body.clone())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn free_tier_copilot_config(_db: &DB, _email: &str) -> Result<Option<AIConfig>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn record_json_usage(_db: DB, _lease: FreeTierLease, _bytes: &[u8]) {}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn meter_usage<S>(
|
||||
upstream: S,
|
||||
_db: DB,
|
||||
_lease: FreeTierLease,
|
||||
) -> impl futures::Stream<Item = std::result::Result<Bytes, reqwest::Error>>
|
||||
where
|
||||
S: futures::Stream<Item = std::result::Result<Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
upstream
|
||||
}
|
||||
@@ -66,6 +66,9 @@ use crate::scim_oss::has_scim_token;
|
||||
use windmill_common::error::AppError;
|
||||
|
||||
mod ai;
|
||||
#[cfg(feature = "private")]
|
||||
mod ai_free_tier_ee;
|
||||
mod ai_free_tier_oss;
|
||||
mod ai_skills;
|
||||
mod apps;
|
||||
mod apps_raw_bundle;
|
||||
|
||||
@@ -18,7 +18,6 @@ use crate::teams_oss::{
|
||||
connect_teams, edit_teams_command, run_teams_message_test_job,
|
||||
workspaces_list_available_teams_channels, workspaces_list_available_teams_ids,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
routing::{get, post},
|
||||
@@ -153,10 +152,23 @@ async fn edit_copilot_config(
|
||||
.await?;
|
||||
let settings_state =
|
||||
build_copilot_settings_state(workspace_has_config, instance_ai_config.as_ref());
|
||||
// A provider-less instance config (e.g. `{}`) is unconfigured, same as build_copilot_settings_state
|
||||
// treats it — so it must not shadow the free-tier fallback here either.
|
||||
let instance_config_with_providers = instance_ai_config
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
|
||||
.filter(|c| c.has_providers());
|
||||
let effective_ai_config = if workspace_has_config {
|
||||
ai_config
|
||||
} else if let Some(instance_ai_config) = instance_ai_config {
|
||||
serde_json::from_value::<AIConfig>(instance_ai_config).unwrap_or_default()
|
||||
} else if let Some(instance_config) = instance_config_with_providers {
|
||||
instance_config
|
||||
} else if let Some(free_config) =
|
||||
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
|
||||
{
|
||||
// Same fallback as get_copilot_info: with nothing configured, surface Windmill's free
|
||||
// tier (EE-only) so clearing a workspace provider activates it immediately, instead of
|
||||
// returning an empty config that disables AI until the next page reload re-fetches it.
|
||||
free_config
|
||||
} else {
|
||||
AIConfig::default()
|
||||
};
|
||||
@@ -179,6 +191,7 @@ struct EditCopilotConfigResponse {
|
||||
}
|
||||
|
||||
async fn get_copilot_info(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<AIConfig> {
|
||||
@@ -194,16 +207,25 @@ async fn get_copilot_info(
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
|
||||
Ok(Json(workspace_ai_config.0))
|
||||
} else if let Some(instance_config) =
|
||||
let instance_config =
|
||||
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.and_then(|v| serde_json::from_value::<AIConfig>(v).ok())
|
||||
// A provider-less instance config (e.g. `{}`) is unconfigured; don't let it shadow the
|
||||
// free-tier fallback, matching the proxy and edit_copilot_config paths.
|
||||
.filter(|c| c.has_providers());
|
||||
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
|
||||
Ok(Json(workspace_ai_config.0))
|
||||
} else if let Some(instance_config) = instance_config {
|
||||
Ok(Json(instance_config))
|
||||
} else if let Some(free_config) =
|
||||
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
|
||||
{
|
||||
Ok(Json(
|
||||
serde_json::from_value::<AIConfig>(instance_config).unwrap_or_default(),
|
||||
))
|
||||
// Nothing configured: fall back to Windmill's free tier (EE-only). The config
|
||||
// carries a `free_tier` marker even once the user's grant is spent — with no
|
||||
// providers, but telling the client *why* AI is off.
|
||||
Ok(Json(free_config))
|
||||
} else {
|
||||
Ok(Json(AIConfig::default()))
|
||||
}
|
||||
@@ -216,7 +238,14 @@ pub async fn get_critical_alerts(
|
||||
authed: ApiAuthed,
|
||||
Query(params): Query<crate::utils::AlertQueryParams>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?;
|
||||
require_admin_or_devops(
|
||||
authed.is_admin,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
authed.job_id.is_some(),
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
crate::utils::get_critical_alerts(db, params, Some(w_id)).await
|
||||
}
|
||||
@@ -232,7 +261,14 @@ pub async fn acknowledge_critical_alert(
|
||||
Path((w_id, id)): Path<(String, i32)>,
|
||||
authed: ApiAuthed,
|
||||
) -> Result<String> {
|
||||
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?;
|
||||
require_admin_or_devops(
|
||||
authed.is_admin,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
authed.job_id.is_some(),
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
crate::utils::acknowledge_critical_alert(db, Some(w_id), id).await
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AIProviderModel,
|
||||
type AIProvider,
|
||||
type AIConfig,
|
||||
type FreeTierInfo,
|
||||
type ModelPriceOverride
|
||||
} from './gen'
|
||||
import {
|
||||
@@ -49,6 +50,10 @@ export const copilotInfo = writable<{
|
||||
/** Negotiated rates per `provider:model`, overriding the built-in price table. */
|
||||
modelPricing?: Record<string, ModelPriceOverride>
|
||||
webSearchEnabledProviders?: Partial<Record<AIProvider, boolean>>
|
||||
// Set only when the workspace has no AI provider of its own and is running on
|
||||
// Windmill's free tier. `exhausted` means the grant is spent: there is no model, but
|
||||
// that is a different state from "never configured" and the UI must say so.
|
||||
freeTier?: FreeTierInfo
|
||||
}>({
|
||||
enabled: false,
|
||||
codeCompletionModel: undefined,
|
||||
@@ -132,8 +137,9 @@ export function setCopilotInfo(aiConfig: AIConfig) {
|
||||
aiModels: aiModels,
|
||||
customPrompts: aiConfig.custom_prompts ?? {},
|
||||
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {},
|
||||
webSearchEnabledProviders,
|
||||
modelPricing: aiConfig.model_pricing ?? {},
|
||||
webSearchEnabledProviders
|
||||
freeTier: aiConfig.free_tier
|
||||
})
|
||||
} else {
|
||||
copilotSessionModel.set(undefined)
|
||||
@@ -146,8 +152,11 @@ export function setCopilotInfo(aiConfig: AIConfig) {
|
||||
aiModels: [],
|
||||
customPrompts: {},
|
||||
maxTokensPerModel: {},
|
||||
webSearchEnabledProviders: {},
|
||||
modelPricing: {},
|
||||
webSearchEnabledProviders: {}
|
||||
// An exhausted free grant lands here — no providers, but the reason AI is off
|
||||
// is "you used it up", not "you never set it up".
|
||||
freeTier: aiConfig.free_tier
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
type: 'string' | 'number' | 'boolean'
|
||||
allowMultiple?: boolean
|
||||
format?: 'json'
|
||||
/** Boolean only: the value the filter holds while unset (defaults to false).
|
||||
* Selecting a filter whose default is false sets it to true immediately rather
|
||||
* than opening a true/false picker whose only useful choice is true. A default-true
|
||||
* boolean still opens the picker, since choosing false is the meaningful action. */
|
||||
default?: boolean
|
||||
}
|
||||
| {
|
||||
type: 'date'
|
||||
@@ -121,16 +126,34 @@
|
||||
// Create the filter instance object
|
||||
const filterInstance: { val: Partial<FilterInstanceRec<T>> } = $state({ val: {} })
|
||||
|
||||
// Sync URL params to filter instance on initialization and when URL changes
|
||||
// Sync URL params to filter instance, reactively. Reading urlFilter[key] tracked
|
||||
// means browser Back/Forward — which mutates useSearchParams' cells on popstate —
|
||||
// flows into the instance (chips, kind toggle, results), not just the first render.
|
||||
// The write happens untracked so it can't self-trigger, and the equality check plus
|
||||
// the reverse effect's own guard keep the two directions from ping-ponging.
|
||||
for (const key of Object.keys(schemaRec)) {
|
||||
let urlValue = urlFilter[key]
|
||||
if (schemaRec[key].type === 'date' && typeof urlValue === 'string') {
|
||||
const d = new Date(urlValue)
|
||||
urlValue = isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
if (urlValue !== undefined && urlValue !== null) {
|
||||
;(filterInstance.val as any)[key] = urlValue
|
||||
}
|
||||
$effect(() => {
|
||||
let urlValue = urlFilter[key]
|
||||
if (schemaRec[key].type === 'date' && typeof urlValue === 'string') {
|
||||
const d = new Date(urlValue)
|
||||
urlValue = isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
untrack(() => {
|
||||
const current = (filterInstance.val as any)[key]
|
||||
const same =
|
||||
urlValue instanceof Date && current instanceof Date
|
||||
? urlValue.getTime() === current.getTime()
|
||||
: current === (urlValue ?? undefined)
|
||||
if (same) return
|
||||
if (urlValue !== undefined && urlValue !== null) {
|
||||
;(filterInstance.val as any)[key] = urlValue
|
||||
} else if (current !== undefined) {
|
||||
// Key dropped from the URL (Back to a state without it): clear it so a
|
||||
// stale chip / filter doesn't linger against the navigated-to URL.
|
||||
delete (filterInstance.val as any)[key]
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Sync filter instance changes back to URL params
|
||||
@@ -275,6 +298,17 @@
|
||||
class?: string
|
||||
placeholder?: string
|
||||
autofocus?: boolean
|
||||
// Applied as the id of the underlying editable, so a parent can focus it or recognise its
|
||||
// key events by id (the searchbar is a contenteditable, not an <input>).
|
||||
inputId?: string
|
||||
// Free-text mode: while the input holds only free text (no specific filter tag is
|
||||
// being edited and no non-default filter is set), suppress the suggestions dropdown
|
||||
// so it behaves like a plain search box. This frees the arrow keys for the
|
||||
// surrounding UI (e.g. a results list). The dropdown returns the moment a specific
|
||||
// filter is present (e.g. `path: u/me/abc`).
|
||||
hideDropdownOnFreeText?: boolean
|
||||
// Notified whenever the dropdown's effective visibility changes
|
||||
onDropdownVisibleChange?: (visible: boolean) => void
|
||||
}
|
||||
|
||||
type SchemaT = FilterSchemaRec // TODO: Generic
|
||||
@@ -284,7 +318,10 @@
|
||||
presets: _presets = [],
|
||||
class: className,
|
||||
placeholder = 'Filter...',
|
||||
autofocus
|
||||
autofocus,
|
||||
hideDropdownOnFreeText = false,
|
||||
onDropdownVisibleChange,
|
||||
inputId
|
||||
}: Props<SchemaT> = $props()
|
||||
|
||||
let _value = new DebouncedTempValue(
|
||||
@@ -298,6 +335,24 @@
|
||||
let currentTag: keyof SchemaT | undefined = $state()
|
||||
let currentTextSegment = $state({ text: '', start: 0, end: 0 })
|
||||
let open = $state(false)
|
||||
|
||||
// A specific filter is in play when a tag is being edited or any non-free-text filter
|
||||
// is set.
|
||||
let hasSpecificFilter = $derived(
|
||||
!!currentTag || Object.keys(value).some((k) => k !== '_default_')
|
||||
)
|
||||
// A plain search term is being typed (free text, no specific filter).
|
||||
let hasFreeText = $derived(!!String(value['_default_'] ?? '').trim())
|
||||
// Effective dropdown visibility. Free-text mode suppresses the dropdown ONLY while the
|
||||
// user is typing a plain search term: it still opens when the input is empty (so the
|
||||
// available filters stay discoverable) and whenever a specific filter is set or being
|
||||
// edited. That leaves the arrow keys for the surrounding list only during free-text search.
|
||||
let dropdownVisible = $derived(
|
||||
open && (!hideDropdownOnFreeText || hasSpecificFilter || !hasFreeText)
|
||||
)
|
||||
$effect(() => {
|
||||
onDropdownVisibleChange?.(dropdownVisible)
|
||||
})
|
||||
let inputElement: HTMLDivElement | undefined = $state()
|
||||
let highlightedIndex = $state(0)
|
||||
let taggedTextInput: TaggedTextInput | undefined = $state()
|
||||
@@ -347,9 +402,17 @@
|
||||
key,
|
||||
filterSchema,
|
||||
onClick: () => {
|
||||
// Replace the text segment with the new filter tag
|
||||
const before = asText.val.slice(0, currentTextSegment.start)
|
||||
const after = asText.val.slice(currentTextSegment.end)
|
||||
if (schema[key].type === 'boolean' && schema[key].default !== true) {
|
||||
// Set the only useful value and reparse to canonical text. The space is
|
||||
// required: dropping the segment must not fuse the tags that flanked it.
|
||||
asText.val = `${before} ${after}`
|
||||
value[key] = true as any
|
||||
asText.reparse()
|
||||
return
|
||||
}
|
||||
// Replace the text segment with the new (empty) filter tag; the value picker opens.
|
||||
asText.val =
|
||||
`${before}${before && !before.endsWith(' ') ? ' ' : ''}${key}:\\\u00A0${after}`.trim() +
|
||||
'\u00A0'
|
||||
@@ -406,6 +469,28 @@
|
||||
onClick: () => setValueForCurrentTag(false)
|
||||
}
|
||||
]
|
||||
} else if (filter.type === 'string' && filter.format !== 'json') {
|
||||
// A plain string filter has no fixed options, but any presets targeting this tag
|
||||
// (`<tag>:<value>`) are exactly its useful values — surface them as suggestions so
|
||||
// picking one is a click, matching the top-level preset row. Unescape the tagged
|
||||
// syntax's `\ ` back to a real space for the stored value.
|
||||
const prefix = `${String(currentTag)}:`
|
||||
const suffix = String(value[currentTag!] ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return _presets
|
||||
.filter((p) => p.value.startsWith(prefix) && !asText.val.includes(p.value))
|
||||
.map((p) => {
|
||||
const raw = p.value.slice(prefix.length).replace(/^\\ /, '').replace(/\\ /g, ' ')
|
||||
return { name: p.name, raw }
|
||||
})
|
||||
.filter((p) => !suffix || p.raw.toLowerCase().includes(suffix))
|
||||
.map((p) => ({
|
||||
type: 'option' as const,
|
||||
option: { value: p.raw, label: p.name },
|
||||
onClick: () => appendOrSetValueForCurrentTag(p.raw),
|
||||
onNegativeClick: undefined
|
||||
}))
|
||||
}
|
||||
}
|
||||
return []
|
||||
@@ -514,7 +599,9 @@
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (!open) return
|
||||
// In free-text mode the dropdown is hidden; let arrow/enter keys pass through to
|
||||
// the surrounding UI (e.g. list navigation) rather than steering a hidden menu.
|
||||
if (!dropdownVisible) return
|
||||
if (e.key === 'Escape') {
|
||||
open = false
|
||||
return
|
||||
@@ -601,6 +688,7 @@
|
||||
>
|
||||
<TaggedTextInput
|
||||
bind:this={taggedTextInput}
|
||||
id={inputId}
|
||||
bind:value={asText.val}
|
||||
{tags}
|
||||
highlights={[
|
||||
@@ -617,7 +705,19 @@
|
||||
inputSizeClasses.md
|
||||
)}
|
||||
{placeholder}
|
||||
onKeyDown={() => (open = true)}
|
||||
onKeyDown={(e) => {
|
||||
// In free-text mode the searchbar coexists with a list that owns Arrow/Enter, so opening
|
||||
// the dropdown on a bare navigation key would steal them from an empty box. Typing, click,
|
||||
// or an already-open dropdown still open/keep it. Other searchbars keep opening on any key.
|
||||
if (
|
||||
!hideDropdownOnFreeText ||
|
||||
!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab'].includes(
|
||||
e.key
|
||||
)
|
||||
) {
|
||||
open = true
|
||||
}
|
||||
}}
|
||||
{autofocus}
|
||||
/>
|
||||
{#if asText.val}
|
||||
@@ -630,9 +730,10 @@
|
||||
</div>
|
||||
|
||||
<GenericDropdown
|
||||
{open}
|
||||
open={dropdownVisible}
|
||||
instantClose={hideDropdownOnFreeText}
|
||||
getInputRect={() => inputElement?.getBoundingClientRect() ?? new DOMRect()}
|
||||
innerClass="!max-h-[30rem]"
|
||||
innerClass="!max-h-[25rem]"
|
||||
strictWidth
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -747,6 +848,20 @@
|
||||
class="border border-border-light rounded min-h-[4rem]"
|
||||
/>
|
||||
</div>
|
||||
{:else if filter.type === 'string'}
|
||||
{#if menuItems.length}
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
{#each menuItems as item, index}
|
||||
{#if item.type === 'option' && item.option}
|
||||
{@render menuItem({
|
||||
onClick: item.onClick,
|
||||
label: item.option.label || item.option.value,
|
||||
highlighted: index === highlightedIndex
|
||||
})}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
onTextSegmentAtCursorChange,
|
||||
onKeyDown,
|
||||
autofocus,
|
||||
id,
|
||||
class: className = ''
|
||||
}: {
|
||||
tags: { regex: RegExp; id: string; onClear?: () => void }[]
|
||||
@@ -18,6 +19,7 @@
|
||||
onTextSegmentAtCursorChange?: (segment: { text: string; start: number; end: number }) => void
|
||||
onKeyDown?: (e: KeyboardEvent) => void
|
||||
autofocus?: boolean
|
||||
id?: string
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
@@ -337,7 +339,13 @@
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
onKeyDown?.(e)
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
|
||||
// Single-line filter input: block Enter's default newline insertion. Surrounding handlers
|
||||
// (suggestion select, list open) still run on bubble; only the contenteditable break is gone.
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') return
|
||||
const cursorPos = getCursorPosition()
|
||||
const text = getTextContent()
|
||||
|
||||
@@ -509,6 +517,7 @@
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<div
|
||||
bind:this={contentEditableDiv}
|
||||
{id}
|
||||
contenteditable="true"
|
||||
oninput={handleInput}
|
||||
onpaste={handlePaste}
|
||||
|
||||
@@ -109,13 +109,13 @@
|
||||
? `${base}/apps${app.raw_app ? '_raw' : ''}/edit/${app.path}`
|
||||
: `${base}/apps${app.raw_app ? '_raw' : ''}/get/${app.path}`}
|
||||
kind="app"
|
||||
{keyboardSelected}
|
||||
{marked}
|
||||
path={(app as any).draft_path ?? app.path}
|
||||
summary={app.is_draft ? `${app.summary || (app as any).draft_path || app.path}*` : app.summary}
|
||||
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={!app.draft_only}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
{rowSelection}
|
||||
>
|
||||
{#snippet badges()}
|
||||
|
||||
@@ -132,13 +132,13 @@
|
||||
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
|
||||
kind="flow"
|
||||
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
|
||||
{keyboardSelected}
|
||||
{marked}
|
||||
path={flow.draft_path ?? flow.path}
|
||||
summary={flow.is_draft ? `${flow.summary || flow.draft_path || flow.path}*` : flow.summary}
|
||||
{errorHandlerMuted}
|
||||
canFavorite={!flow.draft_only}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
{rowSelection}
|
||||
>
|
||||
{#snippet badges()}
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
<Row
|
||||
href="{base}/apps_raw/get/{app.path}"
|
||||
kind="raw_app"
|
||||
{keyboardSelected}
|
||||
{marked}
|
||||
path={app.path}
|
||||
summary={app.summary}
|
||||
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={true}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
>
|
||||
{#snippet badges()}
|
||||
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
interface Props {
|
||||
marked: string | undefined
|
||||
selected?: boolean
|
||||
/** Highlighted by the list's keyboard arrow-navigation (distinct from `selected`,
|
||||
* which is the checkbox multi-select state). Scrolls itself into view. */
|
||||
keyboardSelected?: boolean
|
||||
disabled?: boolean
|
||||
canFavorite?: boolean
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
? `${base}/scripts/edit/${script.path}`
|
||||
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
|
||||
kind="script"
|
||||
{keyboardSelected}
|
||||
{marked}
|
||||
path={script.draft_path ?? script.path}
|
||||
summary={script.is_draft
|
||||
@@ -156,7 +157,6 @@
|
||||
workspaceId={$workspaceStore ?? ''}
|
||||
canFavorite={!script.draft_only}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
{rowSelection}
|
||||
>
|
||||
{#snippet badges()}
|
||||
@@ -187,7 +187,9 @@
|
||||
<Badge small color="yellow" baseClass="border">CI test</Badge>
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if script.kind !== 'script'}
|
||||
<!-- Guard on a non-empty kind: a draft-only script can carry an empty `kind`, which
|
||||
still isn't 'script' and would render an empty blue badge. -->
|
||||
{#if script.kind && script.kind !== 'script'}
|
||||
<Badge color="blue" baseClass="border"
|
||||
>{script.kind === 'failure' ? 'Error handler' : capitalize(script.kind)}</Badge
|
||||
>
|
||||
|
||||
@@ -51,20 +51,26 @@
|
||||
aiChatManager.scriptEditorOptions?.lang &&
|
||||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang))
|
||||
)
|
||||
// A spent free grant is not an unconfigured workspace: AIChatDisplay already shows an
|
||||
// in-thread banner naming the real cause and linking to the key settings, so the generic
|
||||
// "enable Windmill AI" line would both duplicate it and misstate why the chat is off.
|
||||
const freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
|
||||
const disabledMessage = $derived(
|
||||
forceDisabled
|
||||
? forceDisabledMessage
|
||||
: !hasCopilot
|
||||
? $aiUserDisabled
|
||||
? 'Windmill AI is disabled in your account settings'
|
||||
: isAdmin
|
||||
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
|
||||
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
|
||||
: aiChatManager.mode === AIMode.SCRIPT &&
|
||||
aiChatManager.scriptEditorOptions?.lang &&
|
||||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
|
||||
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
|
||||
: ''
|
||||
: freeTierExhausted
|
||||
? ''
|
||||
: !hasCopilot
|
||||
? $aiUserDisabled
|
||||
? 'Windmill AI is disabled in your account settings'
|
||||
: isAdmin
|
||||
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
|
||||
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
|
||||
: aiChatManager.mode === AIMode.SCRIPT &&
|
||||
aiChatManager.scriptEditorOptions?.lang &&
|
||||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
|
||||
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
|
||||
: ''
|
||||
)
|
||||
|
||||
const suggestions = [
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
Folder,
|
||||
Hand,
|
||||
HistoryIcon,
|
||||
KeyRound,
|
||||
MousePointer2,
|
||||
Plug,
|
||||
Plus,
|
||||
@@ -59,9 +60,22 @@
|
||||
readDroppedEntries
|
||||
} from './files/fsAccess'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
const MAX_YOLO_TOOLTIP_TOOLS = 8
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
// The user spent their one-time free Windmill AI grant: there is no model left to send
|
||||
// to, so say so in the thread itself rather than only failing on send.
|
||||
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
|
||||
// Still on the free grant: keep how much is left in view right above the composer, so
|
||||
// running out isn't a surprise. Once spent, the exhausted banner replaces it.
|
||||
let freeTier = $derived($copilotInfo.freeTier)
|
||||
let freeTierUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
|
||||
let showFreeTierUsage = $derived(!!freeTier && !freeTier.exhausted)
|
||||
|
||||
// One row per autonomy posture, in picker order, so adding one touches only this
|
||||
// table. `isAvailable` hides the postures that would do nothing in the current AI
|
||||
// mode, which is why the picker can be shorter than this list.
|
||||
@@ -562,6 +576,44 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet freeTierExhaustedBanner()}
|
||||
<div class="my-2">
|
||||
<Alert type="info" size="xs" title="Free Windmill AI used up">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
You have used all of your free Windmill AI tokens. Add your own API key to keep using AI.
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="2xs"
|
||||
variant="accent"
|
||||
startIcon={{ icon: KeyRound }}
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
>
|
||||
Add your own API key
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet freeTierUsageBanner()}
|
||||
<div
|
||||
class="my-1 flex items-center justify-between gap-2 rounded-md border bg-surface-secondary px-2 py-1"
|
||||
>
|
||||
<span class="text-xs text-secondary tabular-nums">
|
||||
{freeTierUsedPct}% of your free Windmill AI used
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="2xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: KeyRound }}
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
>
|
||||
Configure your API key
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- tabindex="-1": clicks on non-focusable chat content must move focus into
|
||||
the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
<div
|
||||
@@ -675,6 +727,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
script editor to modify selected lines.</span
|
||||
>
|
||||
{/if}
|
||||
{#if freeTierExhausted}
|
||||
<div class={wideLayout ? 'w-full max-w-3xl mx-auto px-7' : 'w-full max-w-2xl mx-auto px-3'}>
|
||||
{@render freeTierExhaustedBanner()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if messages.length > 0}
|
||||
@@ -699,6 +756,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
isLast={messageIndex === messages.length - 1}
|
||||
/>
|
||||
{/each}
|
||||
{#if freeTierExhausted}
|
||||
{@render freeTierExhaustedBanner()}
|
||||
{/if}
|
||||
{#if showTypingIndicator}
|
||||
<div
|
||||
class={twMerge(
|
||||
@@ -798,6 +858,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{#if inputPreface}
|
||||
{@render inputPreface()}
|
||||
{/if}
|
||||
{#if showFreeTierUsage}
|
||||
{@render freeTierUsageBanner()}
|
||||
{/if}
|
||||
<AIChatInput
|
||||
bind:this={aiChatInput}
|
||||
bind:selectedContext
|
||||
|
||||
@@ -85,6 +85,8 @@ import { untrack } from 'svelte'
|
||||
import { get } from 'svelte/store'
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { workspaceStore, type DBSchemas } from '$lib/stores'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot'
|
||||
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
|
||||
import { readDocsPageTool, searchDocsTool } from './docs/core'
|
||||
import { TypewriterReveal } from './typewriterReveal'
|
||||
@@ -102,11 +104,7 @@ import type AIChatInput from './AIChatInput.svelte'
|
||||
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
|
||||
import { closeInterruptedToolBatch, runChatLoop, truncateToToolPairedPrefix } from './chatLoop'
|
||||
import { sanitizeToolCallArguments } from './toolCallArguments'
|
||||
import {
|
||||
billedTokens,
|
||||
normalizeContextUsage,
|
||||
type ChatTokenUsage
|
||||
} from './tokenUsage'
|
||||
import { billedTokens, normalizeContextUsage, type ChatTokenUsage } from './tokenUsage'
|
||||
import { logAiUsage } from '$lib/utils/aiUsageReporter'
|
||||
import type { ReviewChangesOpts } from './monaco-adapter'
|
||||
import {
|
||||
@@ -369,6 +367,25 @@ function getSendRequestErrorMessage(err: unknown, webSearchUnavailable: boolean)
|
||||
return appendWebSearchErrorHint(message, webSearchUnavailable)
|
||||
}
|
||||
|
||||
/** Re-fetch copilotInfo after a free-tier turn so the usage banner tracks spend live and the
|
||||
* exhausting turn flips `freeTier.exhausted`; otherwise these update only on the next workspace
|
||||
* load. Scoped to a live (non-exhausted) free tier so configured-key users pay no extra request. */
|
||||
async function refreshFreeTierUsage(workspace: string | undefined) {
|
||||
if (!workspace) return
|
||||
// copilotInfo is a singleton shared across sessions: a warm session finishing after a
|
||||
// workspace switch must not loadCopilot for its now-background workspace. Gate on the
|
||||
// most-recently-*requested* workspace (set synchronously) so a refresh can't win the
|
||||
// monotonic token over a newer load still in flight.
|
||||
if (get(copilotWorkspaceRequested) !== workspace) return
|
||||
const info = get(copilotInfo)
|
||||
if (!info.freeTier || info.freeTier.exhausted) return
|
||||
try {
|
||||
await loadCopilot(workspace)
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh free-tier usage', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** A message queued while a turn streams: the draft lanes and the pinned
|
||||
* context snapshot always move together so a flush can't drop one. */
|
||||
type QueuedEntry = {
|
||||
@@ -3713,6 +3730,9 @@ export class AIChatManager {
|
||||
// releases the loop; it never discards uncommitted text.
|
||||
this.replyReveal.reset()
|
||||
this.reasoningReveal.reset()
|
||||
// Refresh the free-tier usage meter after every turn (success or error), and
|
||||
// let the turn that exhausts the grant flip to the exhausted state live.
|
||||
void refreshFreeTierUsage(this.operatingWorkspace)
|
||||
}
|
||||
// Flush the queued message. Send it after a cleanly committed turn OR a
|
||||
// deliberate user cancel (Esc / Stop) — in both cases the user is ready
|
||||
|
||||
@@ -46,6 +46,13 @@
|
||||
)
|
||||
let models = $derived($copilotInfo.aiModels)
|
||||
|
||||
// Free tier: the workspace has no key of its own and is spending Windmill's one-time
|
||||
// grant. Label it so the user knows whose budget this is, and warn before it runs out
|
||||
// rather than letting the grant die mid-task.
|
||||
let freeTier = $derived($copilotInfo.freeTier)
|
||||
let freeUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
|
||||
let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80)
|
||||
|
||||
let capability = $derived(
|
||||
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
|
||||
)
|
||||
@@ -312,6 +319,13 @@
|
||||
{#if effortLabel}
|
||||
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
|
||||
{/if}
|
||||
{#if freeTier && !freeTier.exhausted}
|
||||
<span
|
||||
class="shrink-0 rounded-full px-1.5 text-2xs {freeRunningLow
|
||||
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
|
||||
: 'bg-surface-secondary text-tertiary'}">Free</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { getKnownModelContextWindow, getModelContextWindow } from '../modelConfig'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import UsageMeter from './UsageMeter.svelte'
|
||||
import { formatTokenCount } from './tokenUsage'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
@@ -49,25 +49,11 @@
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<Tooltip small placement="top">
|
||||
<!-- Only a meter when a model is configured: it's a 0–100% reading against the
|
||||
window compaction enforces (known or assumed). With no model there's no max
|
||||
to measure against, so it's a plain labeled indicator (the bar is
|
||||
decorative/full and the token count lives in the tooltip). -->
|
||||
<div
|
||||
class="flex items-center h-5"
|
||||
aria-label="Context window usage"
|
||||
role={fillPct !== undefined ? 'meter' : undefined}
|
||||
aria-valuenow={fillPct}
|
||||
aria-valuemin={fillPct !== undefined ? 0 : undefined}
|
||||
aria-valuemax={fillPct !== undefined ? 100 : undefined}
|
||||
>
|
||||
<div class="w-8 h-1.5 rounded-full bg-surface-secondary overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all {fillClass}" style="width: {fillPct ?? 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{#snippet text()}
|
||||
<!-- Only a meter when we know the window: it's a 0–100% reading. With an unknown
|
||||
window there's no max to measure against, so it's a plain labeled indicator
|
||||
(the bar is decorative/full and the token count lives in the tooltip). -->
|
||||
<UsageMeter {fillPct} {fillClass} ariaLabel="Context window usage">
|
||||
{#snippet tooltip()}
|
||||
<div class="text-xs whitespace-nowrap">
|
||||
<p class="font-semibold">Context usage</p>
|
||||
<p class="mt-1 tabular-nums">
|
||||
@@ -85,5 +71,5 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
</UsageMeter>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
|
||||
// Presentational gauge used by the context-usage indicator: a thin bar in a tooltip.
|
||||
// The owner computes the fill and supplies the tooltip content.
|
||||
let {
|
||||
fillPct,
|
||||
fillClass,
|
||||
ariaLabel,
|
||||
tooltip
|
||||
}: {
|
||||
// 0–100. Undefined means "no known max": the bar is decorative (full) and carries
|
||||
// no meter role.
|
||||
fillPct?: number
|
||||
fillClass: string
|
||||
ariaLabel: string
|
||||
tooltip: Snippet
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<Tooltip small placement="top">
|
||||
<div
|
||||
class="flex items-center h-5"
|
||||
aria-label={ariaLabel}
|
||||
role={fillPct !== undefined ? 'meter' : undefined}
|
||||
aria-valuenow={fillPct}
|
||||
aria-valuemin={fillPct !== undefined ? 0 : undefined}
|
||||
aria-valuemax={fillPct !== undefined ? 100 : undefined}
|
||||
>
|
||||
<div class="w-8 h-1.5 rounded-full bg-surface-secondary overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all {fillClass}" style="width: {fillPct ?? 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{#snippet text()}
|
||||
{@render tooltip()}
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
@@ -1,6 +1,14 @@
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { copilotWorkspace, setCopilotInfo } from '$lib/aiStore'
|
||||
import { workspaceAIClients } from './lib'
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
// The workspace of the most recent loadCopilot *request*, set synchronously before the
|
||||
// await — as opposed to `copilotWorkspace`, which only updates once a load resolves. A
|
||||
// background refresh (e.g. free-tier usage) compares against this so it can't supersede an
|
||||
// in-flight load for a newer workspace (which would otherwise win the token and restore
|
||||
// stale state).
|
||||
export const copilotWorkspaceRequested = writable<string | undefined>(undefined)
|
||||
|
||||
// Lives here, not in $lib/aiStore, purely so that module needs no import of the AI
|
||||
// client — it is the one thing that wanted both. Moving it back recreates the
|
||||
@@ -20,6 +28,7 @@ let loadCopilotToken = 0
|
||||
let inFlight: { workspace: string; promise: Promise<void> } | undefined
|
||||
|
||||
export function loadCopilot(workspace: string): Promise<void> {
|
||||
copilotWorkspaceRequested.set(workspace)
|
||||
if (inFlight?.workspace === workspace) {
|
||||
return inFlight.promise
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<script lang="ts" module>
|
||||
/** Example prompts: the short `label` is shown as a clickable tag under the chat,
|
||||
* the `prompt` is what gets typed out as the placeholder / dropped into the input. */
|
||||
export const homeAIExamples: { label: string; prompt: string }[] = [
|
||||
{
|
||||
label: 'Sync Salesforce',
|
||||
prompt: 'Sync new Salesforce leads into a postgres table every hour'
|
||||
},
|
||||
{
|
||||
label: 'Ban Discord users',
|
||||
prompt:
|
||||
'Build a workflow that triggers on a Discord message, checks for offensive language using an LLM, and possibly block them'
|
||||
},
|
||||
{
|
||||
label: 'Weekly Slack report',
|
||||
prompt: 'Generate a weekly sales report from postgres and post it to Slack every Monday'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { ArrowUp, ExternalLink, Globe2, KeyRound, PlugZap, Settings } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { Badge } from '../common'
|
||||
import { startSessionWithPrompt } from '../sessions/sessionSwitch.svelte'
|
||||
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
|
||||
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
|
||||
import { aiUserDisabled, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import { HOME_SHOW_HUB } from '$lib/consts'
|
||||
import { base } from '$lib/base'
|
||||
import AIChatModelSettings from '../copilot/chat/AIChatModelSettings.svelte'
|
||||
import HomeConnectDrawer from './HomeConnectDrawer.svelte'
|
||||
import { USER_SETTINGS_HASH } from '../sidebar/settings'
|
||||
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
|
||||
|
||||
let value = $state('')
|
||||
let placeholder = $state('')
|
||||
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
|
||||
|
||||
// In global-AI mode the layout's chat panel is disabled and never loads the copilot
|
||||
// config, so the home chat loads it for the current workspace itself.
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
loadCopilot($workspaceStore)
|
||||
}
|
||||
})
|
||||
|
||||
// Whether the copilot config has actually loaded for the current workspace.
|
||||
let configLoaded = $derived($copilotWorkspace === $workspaceStore)
|
||||
// No usable model (no provider configured, or AI disabled): the composer is blurred and an
|
||||
// overlay explains why and links to the fix. Static, not hover-gated, so keyboard and touch
|
||||
// users see it too. Gate on `configLoaded` so the initial (unloaded) state doesn't flash the
|
||||
// overlay while a provider is in fact configured.
|
||||
let disabled = $derived(configLoaded && !$copilotInfo.enabled)
|
||||
// Submission is stricter than the overlay: block it until the config is loaded AND
|
||||
// enabled. Submitting during the unknown-config window hands the prompt to a session
|
||||
// that only sends once `copilotInfo.enabled` flips true — on an unconfigured/disabled
|
||||
// workspace that never happens and the prompt is silently lost.
|
||||
let canSend = $derived(configLoaded && $copilotInfo.enabled)
|
||||
|
||||
// Applied to the AI-specific parts only (title, input, example tags) when disabled. The
|
||||
// CLI/MCP and Hub buttons are unrelated to AI and stay sharp and clickable.
|
||||
let blurClass = $derived(disabled ? 'blur-sm pointer-events-none select-none' : '')
|
||||
|
||||
// Disabled because the user spent their free Windmill AI grant, not because AI was never
|
||||
// set up — the two look identical otherwise, and the "configure AI" copy would be a lie.
|
||||
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
|
||||
|
||||
// The composer hands off to /sessions, which refuses operators — so hide it from them (the
|
||||
// prompt would be silently dropped) while the AI-independent CLI/MCP row below stays.
|
||||
let showComposer = $derived(prefersSessionHandoff($userStore?.operator))
|
||||
|
||||
let starting = $state(false)
|
||||
async function start() {
|
||||
if (!canSend || starting || !value.trim()) return
|
||||
starting = true
|
||||
try {
|
||||
await startSessionWithPrompt(value, { autoSend: true })
|
||||
} finally {
|
||||
starting = false
|
||||
}
|
||||
}
|
||||
|
||||
// Enter starts the session; Shift+Enter keeps inserting a newline.
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
const prompts = homeAIExamples.map((e) => e.prompt)
|
||||
|
||||
const TYPE_MS = 45
|
||||
const DELETE_MS = 25
|
||||
const HOLD_MS = 1800
|
||||
const PAUSE_MS = 400
|
||||
|
||||
// Typewriter effect: type a prompt out, hold, delete, then advance to the next. Only while the
|
||||
// composer is shown — otherwise (operators) it would loop forever driving an unrendered input.
|
||||
$effect(() => {
|
||||
if (!showComposer) return
|
||||
let promptIndex = 0
|
||||
let charIndex = 0
|
||||
let deleting = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
|
||||
function tick() {
|
||||
const current = prompts[promptIndex]
|
||||
if (!deleting) {
|
||||
charIndex++
|
||||
placeholder = current.slice(0, charIndex)
|
||||
if (charIndex >= current.length) {
|
||||
deleting = true
|
||||
timer = setTimeout(tick, HOLD_MS)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(tick, TYPE_MS)
|
||||
} else {
|
||||
charIndex--
|
||||
placeholder = current.slice(0, charIndex)
|
||||
if (charIndex <= 0) {
|
||||
deleting = false
|
||||
promptIndex = (promptIndex + 1) % prompts.length
|
||||
timer = setTimeout(tick, PAUSE_MS)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(tick, DELETE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(tick, TYPE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="max-w-[40rem] grow relative group">
|
||||
{#if showComposer}
|
||||
<div class={blurClass} inert={disabled}>
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<p class="text-center font-regular text-3xl">Build with AI</p>
|
||||
<Badge color="blue" small>Beta</Badge>
|
||||
</div>
|
||||
<!-- anchors the send button / model settings to the input, not to the whole
|
||||
block — the row below would otherwise push them down -->
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
bind:value
|
||||
class="resize-none px-4 py-3 pb-9 shadow-sm border-accent"
|
||||
underlyingInputEl="textarea"
|
||||
inputProps={{ rows: 4, placeholder, onkeydown: onKeydown }}
|
||||
/>
|
||||
<Button
|
||||
endIcon={starting ? {} : { icon: ArrowUp }}
|
||||
wrapperClasses="absolute right-2 bottom-3.5"
|
||||
variant={value.trim() ? 'accent' : 'subtle'}
|
||||
iconOnly
|
||||
loading={starting}
|
||||
disabled={!value.trim() || starting || !canSend}
|
||||
onclick={start}
|
||||
></Button>
|
||||
<div class="absolute left-3 bottom-4 flex items-center gap-1.5 px-0.5">
|
||||
<AIChatModelSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
{#if showComposer}
|
||||
<div class="flex flex-row flex-wrap items-center gap-1.5 {blurClass}" inert={disabled}>
|
||||
{#each homeAIExamples as example (example.label)}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!rounded-full !text-2xs !text-hint"
|
||||
onClick={() => (value = example.prompt)}
|
||||
>
|
||||
{example.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<!-- Not AI-related, so shown even to operators / when the composer is hidden: kept out of
|
||||
the blurred subtree and above the disabled overlay so it stays sharp and clickable. -->
|
||||
<div class="relative z-20 flex flex-row items-center gap-1">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!text-2xs !text-hint"
|
||||
startIcon={{ icon: PlugZap }}
|
||||
onClick={() => homeConnectDrawer?.openDrawer?.()}
|
||||
>
|
||||
CLI / MCP
|
||||
</Button>
|
||||
{#if !$userStore?.operator && HOME_SHOW_HUB}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!text-2xs !text-hint"
|
||||
startIcon={{ icon: Globe2 }}
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
href={$hubBaseUrlStore}
|
||||
target="_blank"
|
||||
>
|
||||
Hub
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if showComposer && disabled}
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-md bg-surface/70"
|
||||
>
|
||||
<p class="text-sm text-secondary">
|
||||
{#if $aiUserDisabled}
|
||||
Windmill AI is disabled in your account settings
|
||||
{:else if freeTierExhausted}
|
||||
You have used all of your free Windmill AI tokens
|
||||
{:else}
|
||||
No AI provider is configured
|
||||
{/if}
|
||||
</p>
|
||||
{#if $aiUserDisabled}
|
||||
<!-- The fix lives in account settings (a hash-opened drawer, not a route), so link
|
||||
the hash the sidebar's Account menu uses rather than the workspace AI settings. -->
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Settings }}
|
||||
href={USER_SETTINGS_HASH}
|
||||
>
|
||||
Open account settings
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
startIcon={{ icon: freeTierExhausted ? KeyRound : Settings }}
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
>
|
||||
{freeTierExhausted ? 'Add your own API key' : 'Configure AI'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeConnectDrawer bind:this={homeConnectDrawer} />
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { PIPELINE_DRAFT_KIND, pipelineFolderFromBundlePath } from '$lib/pipelinePaths'
|
||||
import { Badge, Button, Skeleton } from '$lib/components/common'
|
||||
import { Button, Skeleton } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import {
|
||||
AssetService,
|
||||
@@ -24,51 +24,176 @@
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Code2,
|
||||
LayoutDashboard,
|
||||
ListFilterPlus,
|
||||
SearchCode,
|
||||
Tag
|
||||
LayoutDashboard
|
||||
} from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import CreateActionsMenu from './CreateActionsMenu.svelte'
|
||||
import ContentSearchInner from '$lib/components/ContentSearchInner.svelte'
|
||||
import type { Item as MenuItem } from '$lib/utils'
|
||||
|
||||
import { HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts'
|
||||
|
||||
import SearchItems from '../SearchItems.svelte'
|
||||
import ListFilters from './ListFilters.svelte'
|
||||
import FilterSearchbar, {
|
||||
useUrlSyncedFilterInstance,
|
||||
type FilterSchemaRec
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import NoItemFound from './NoItemFound.svelte'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
import FlowIcon from './FlowIcon.svelte'
|
||||
import { canWrite, getLocalSetting, isOwner, storeLocalSetting } from '$lib/utils'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { page } from '$app/state'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import Drawer from '../common/drawer/Drawer.svelte'
|
||||
import HighlightCode from '../HighlightCode.svelte'
|
||||
import DrawerContent from '../common/drawer/DrawerContent.svelte'
|
||||
import Item from './Item.svelte'
|
||||
import TreeViewRoot from './TreeViewRoot.svelte'
|
||||
import { effectivePath, type ItemType } from './treeViewUtils'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { getContext, tick, untrack } from 'svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { NetworkIcon } from 'lucide-svelte'
|
||||
import { base } from '$lib/base'
|
||||
import BulkActionsBar from './BulkActionsBar.svelte'
|
||||
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
|
||||
interface Props {
|
||||
filter?: string
|
||||
subtab?: 'flow' | 'script' | 'app'
|
||||
showEditButtons?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
filter = $bindable(''),
|
||||
subtab = $bindable('script'),
|
||||
showEditButtons = true
|
||||
}: Props = $props()
|
||||
let { subtab = $bindable('script'), showEditButtons = true }: Props = $props()
|
||||
|
||||
// Which user-folder scoping toggle (if any) this role gets. Declared before the
|
||||
// FilterSearchbar schema and the derived filters that both read it.
|
||||
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
|
||||
$userStore?.non_member
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
|
||||
// FilterSearchbar schema — `_default_` is the free-text search; the rest mirror the
|
||||
// boolean/kind list filters. Owner and label scoping are offered as searchbar presets
|
||||
// (searchPresets) and resolve server-side (path_start / label) rather than filtering
|
||||
// client-side. `content` is a distinct mode: it swaps the list for the client-side
|
||||
// content-match view below (usable on any instance, not EE-gated).
|
||||
let searchFilterSchema = $derived({
|
||||
_default_: { type: 'string' as const, hidden: true },
|
||||
content: {
|
||||
type: 'string' as const,
|
||||
label: 'Content',
|
||||
description: 'Search across item contents'
|
||||
},
|
||||
// Owner (u/<user> or f/<folder>) and label are offered as presets built from what the
|
||||
// list actually holds (see searchPresets); they drive the same server path-scope / label
|
||||
// filter the old on-page chips did.
|
||||
owner: { type: 'string' as const, label: 'Owner' },
|
||||
label: { type: 'string' as const, label: 'Label' },
|
||||
kind: {
|
||||
type: 'oneof' as const,
|
||||
label: 'Kind',
|
||||
options: [
|
||||
{ value: 'script', label: 'Script' },
|
||||
...(HOME_SEARCH_SHOW_FLOW ? [{ value: 'flow', label: 'Flow' }] : []),
|
||||
{ value: 'app', label: 'App' }
|
||||
]
|
||||
},
|
||||
archived: { type: 'boolean' as const, label: 'Only archived' },
|
||||
// include_library and only_user_folders are role-dependent, but their KEYS stay unconditional
|
||||
// (toggling `hidden` instead): useUrlSyncedFilterInstance snapshots the key set once and Home
|
||||
// survives workspace switches, so a key first appearing after a role change would never
|
||||
// URL-sync. The searchbar hides the inactive ones.
|
||||
include_library: {
|
||||
type: 'boolean' as const,
|
||||
label: 'Include library scripts',
|
||||
// On by default, so selecting it means "turn it off" — keep the picker.
|
||||
default: true,
|
||||
hidden: !($userStore && !$userStore.operator)
|
||||
},
|
||||
only_user_folders: {
|
||||
type: 'boolean' as const,
|
||||
label:
|
||||
filterUserFoldersType === 'only f/*'
|
||||
? 'Only f/*'
|
||||
: `Only u/${$userStore?.username} and f/*`,
|
||||
hidden: !filterUserFoldersType
|
||||
}
|
||||
} satisfies FilterSchemaRec)
|
||||
|
||||
// Legacy Home links stored free-text in `search`, owner scope in `filter`, and could carry
|
||||
// `kind=all` — none of which the generic searchbar sync (keys `_default_`, `owner`, and a kind
|
||||
// enum without `all`) understands. Rewrite them once, before the sync reads window.location, so
|
||||
// shared/bookmarked URLs still restore and an invalid `kind=all` can't wedge later edits.
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = new URL(window.location.href)
|
||||
const p = url.searchParams
|
||||
let changed = false
|
||||
const legacySearch = p.get('search')
|
||||
if (legacySearch !== null) {
|
||||
if (!p.has('_default_')) p.set('_default_', legacySearch)
|
||||
p.delete('search')
|
||||
changed = true
|
||||
}
|
||||
const legacyOwner = p.get('filter')
|
||||
if (legacyOwner !== null) {
|
||||
if (!p.has('owner')) p.set('owner', legacyOwner)
|
||||
p.delete('filter')
|
||||
changed = true
|
||||
}
|
||||
if (p.get('kind') === 'all') {
|
||||
p.delete('kind')
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
history.replaceState(
|
||||
history.state,
|
||||
'',
|
||||
`${url.pathname}${p.toString() ? `?${p}` : ''}${url.hash}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Single URL-synced source of truth for the searchbar-driven filters.
|
||||
let filterValues = useUrlSyncedFilterInstance(untrack(() => searchFilterSchema))
|
||||
|
||||
// Derived views the rest of the data layer reads. The merged-endpoint reload effect
|
||||
// depends on these (search, kind, archived, library, user-folder scope), so changing a
|
||||
// searchbar chip reloads the server stream exactly as toggling the old controls did.
|
||||
let filter = $derived((filterValues.val._default_ ?? '') as string)
|
||||
let itemKind = $derived((filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'all')
|
||||
let archived = $derived(!!filterValues.val.archived)
|
||||
let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean)
|
||||
let filterUserFolders = $derived(!!filterValues.val.only_user_folders)
|
||||
|
||||
// Content search is a distinct mode: its results come from ContentSearchInner
|
||||
// (which carries only path + content), so the row-list filters can't
|
||||
// apply to it. When it's active we restrict the searchbar to just the content filter,
|
||||
// clear any other filters so they don't linger as ignored chips, and hide the row-list
|
||||
// controls (kind toggle, tree view) that no longer drive anything.
|
||||
let contentActive = $derived(!!filterValues.val.content)
|
||||
let searchbarSchema = $derived(
|
||||
contentActive ? { content: searchFilterSchema.content } : searchFilterSchema
|
||||
)
|
||||
$effect(() => {
|
||||
if (!contentActive) return
|
||||
untrack(() => {
|
||||
for (const k of Object.keys(filterValues.val)) {
|
||||
if (k !== 'content') delete (filterValues.val as Record<string, unknown>)[k]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Content-filter view: reuse the Ctrl-K "Content" search. It loads its own dataset
|
||||
// via `.open()`, then filters client-side by `search`. The component is
|
||||
// keyed by workspace in the markup, so a workspace switch remounts it (this `bind:this`
|
||||
// then points at the fresh instance and re-runs `open()`); the old instance is discarded,
|
||||
// so its late in-flight responses can't overwrite the new workspace's results.
|
||||
let contentSearchEl: ContentSearchInner | undefined = $state()
|
||||
$effect(() => {
|
||||
const el = contentSearchEl
|
||||
if (el) untrack(() => el.open())
|
||||
})
|
||||
|
||||
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
|
||||
canWrite: boolean
|
||||
@@ -178,10 +303,6 @@
|
||||
|
||||
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
|
||||
|
||||
let itemKind = $state(
|
||||
(page.url.searchParams.get('kind') as 'script' | 'flow' | 'app' | 'all') ?? 'all'
|
||||
)
|
||||
|
||||
let loading = $state(true)
|
||||
|
||||
let nbDisplayed = $state(15)
|
||||
@@ -596,8 +717,11 @@
|
||||
return true // should not happen
|
||||
}
|
||||
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let labelFilter: string | undefined = $state(undefined)
|
||||
// Owner/label scope now live on the URL-synced searchbar filters (set via the presets),
|
||||
// not standalone chip state — the whole data layer below still reads these two, so keep
|
||||
// them as the single derived source. Empty string reads as "no filter".
|
||||
let ownerFilter = $derived((filterValues.val.owner || undefined) as string | undefined)
|
||||
let labelFilter = $derived((filterValues.val.label || undefined) as string | undefined)
|
||||
|
||||
const cmp = new Intl.Collator('en').compare
|
||||
|
||||
@@ -692,20 +816,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
let archived = $state(false)
|
||||
|
||||
const TREE_VIEW_SETTING_NAME = 'treeView'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'filterUserFolders'
|
||||
const INCLUDE_WITHOUT_MAIN_SETTING_NAME = 'includeWithoutMain'
|
||||
let treeView = $state(getLocalSetting(TREE_VIEW_SETTING_NAME) == 'true')
|
||||
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
|
||||
$userStore?.non_member
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
// Pipeline entries are rendered independently of the item list, so apply the
|
||||
// same gates the items get — otherwise a pipeline would still show under the
|
||||
@@ -725,16 +837,6 @@
|
||||
)
|
||||
)
|
||||
})
|
||||
let includeWithoutMain = $state(
|
||||
getLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME)
|
||||
? getLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME) == 'true'
|
||||
: true
|
||||
)
|
||||
|
||||
const openSearchWithPrefilledText: (t?: string) => void = getContext(
|
||||
'openSearchWithPrefilledText'
|
||||
)
|
||||
|
||||
let viewCodeDrawer: Drawer | undefined = $state()
|
||||
let viewCodeTitle: string | undefined = $state()
|
||||
let script: Script | undefined = $state()
|
||||
@@ -1056,15 +1158,25 @@
|
||||
let allLabels = $derived(
|
||||
Array.from(new Set(combinedItems?.flatMap((x) => itemLabels(x)) ?? [])).sort()
|
||||
)
|
||||
// FilterSearchbar presets: the owner prefixes and labels the list actually holds, so
|
||||
// scoping to one is a click in the searchbar dropdown instead of a wall of on-page chips.
|
||||
// Owner sets the `owner` filter (server path-scope), label sets `label` (client filter).
|
||||
// The `:\ ` separator and escaped spaces match the canonical `key:\ value` form parseToText
|
||||
// emits, so the "already applied" check finds them after a reparse and won't re-offer a
|
||||
// duplicate.
|
||||
let searchPresets = $derived([
|
||||
...owners.map((o) => ({ name: o, value: `owner:\\ ${o.replace(/ /g, '\\ ')}` })),
|
||||
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l.replace(/ /g, '\\ ')}` }))
|
||||
])
|
||||
let prevWorkspace: string | undefined = undefined
|
||||
// Clear filters only when the workspace actually changes. The initial
|
||||
// resolution must be left alone so URL-loaded filter values (set by
|
||||
// ListFilters.loadFilterFromUrl on mount) survive the async store settling.
|
||||
// An owner/label from one workspace means nothing in another, so drop them when the
|
||||
// workspace actually changes. The initial resolution is left alone so URL-loaded filter
|
||||
// values survive the async store settling.
|
||||
$effect(() => {
|
||||
const ws = $workspaceStore
|
||||
if (ws && prevWorkspace !== undefined && ws !== prevWorkspace) {
|
||||
ownerFilter = undefined
|
||||
labelFilter = undefined
|
||||
delete filterValues.val.owner
|
||||
delete filterValues.val.label
|
||||
}
|
||||
prevWorkspace = ws
|
||||
})
|
||||
@@ -1211,6 +1323,26 @@
|
||||
selectedIndex = previousNbDisplayed
|
||||
}
|
||||
|
||||
// The searchbar is a contenteditable, not an <input>, so it can't be matched by SKIP_SELECTOR
|
||||
// and has no `.value`/`.selectionEnd`. It owns the arrows only while its suggestion dropdown is
|
||||
// open (free-text mode passes them through to the list); track that so nav stands down then.
|
||||
let searchbarDropdownOpen = $state(false)
|
||||
|
||||
// Caret position inside the searchbar's contenteditable, via the Selection API — the equivalent
|
||||
// of an <input>'s selectionStart/End the list navigation used before the searchbar swap.
|
||||
function searchCaret(el: HTMLElement): { atStart: boolean; atEnd: boolean; empty: boolean } {
|
||||
const text = el.textContent ?? ''
|
||||
const sel = window.getSelection()
|
||||
if (!sel || sel.rangeCount === 0)
|
||||
return { atStart: true, atEnd: true, empty: text.length === 0 }
|
||||
const range = sel.getRangeAt(0)
|
||||
const pre = range.cloneRange()
|
||||
pre.selectNodeContents(el)
|
||||
pre.setEnd(range.endContainer, range.endOffset)
|
||||
const caret = pre.toString().length
|
||||
return { atStart: caret === 0, atEnd: caret >= text.length, empty: text.length === 0 }
|
||||
}
|
||||
|
||||
// Elements that own the keyboard themselves (menus, dialogs, comboboxes): the
|
||||
// list's own shortcuts stand down while one of them has focus.
|
||||
const SKIP_SELECTOR =
|
||||
@@ -1331,6 +1463,8 @@
|
||||
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
|
||||
const isOurSearch = target.id === 'home-search-input'
|
||||
if (isEditable && !isOurSearch) return
|
||||
// While the searchbar's suggestion dropdown is open it owns the arrows/Enter itself.
|
||||
if (isOurSearch && searchbarDropdownOpen) return
|
||||
if (target.closest(skipSelector)) return
|
||||
}
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
@@ -1340,8 +1474,8 @@
|
||||
// Guard: if cursor is in the middle of typed search text, let the cursor move.
|
||||
if (e.key === 'ArrowRight') {
|
||||
if (target?.id === 'home-search-input') {
|
||||
const inp = target as HTMLInputElement
|
||||
if (inp.value.length > 0 && inp.selectionEnd !== inp.value.length) return
|
||||
const c = searchCaret(target)
|
||||
if (!c.empty && !c.atEnd) return
|
||||
}
|
||||
if (selectedIndex < 0 || selectedIndex >= displayedItems.length) return
|
||||
const buttons = getSelectedRowActionButtons()
|
||||
@@ -1354,8 +1488,8 @@
|
||||
// ArrowLeft from search input with cursor at start: no-op (let default handle).
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (target?.id === 'home-search-input') {
|
||||
const inp = target as HTMLInputElement
|
||||
if (inp.value.length > 0 && inp.selectionStart !== 0) return
|
||||
const c = searchCaret(target)
|
||||
if (!c.empty && !c.atStart) return
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1427,13 +1561,6 @@
|
||||
$effect(() => {
|
||||
storeLocalSetting(TREE_VIEW_SETTING_NAME, treeView ? 'true' : undefined)
|
||||
})
|
||||
$effect(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
$effect(() => {
|
||||
storeLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME, includeWithoutMain ? 'true' : undefined)
|
||||
})
|
||||
|
||||
// Multi-selection + bulk actions. Published through context so the tree's
|
||||
// nested levels don't have to carry it; `Item` is the only reader.
|
||||
const homeSelection = new HomeSelection()
|
||||
@@ -1497,157 +1624,77 @@
|
||||
description: 'Lists of scripts, flows, and apps'
|
||||
}}
|
||||
>
|
||||
<div class="flex justify-start">
|
||||
<ToggleButtonGroup
|
||||
bind:selected={itemKind}
|
||||
onSelected={(v) => {
|
||||
if (itemKind != 'all') {
|
||||
subtab = v
|
||||
}
|
||||
setQuery('kind', v)
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="all" label="All" size="md" {item} />
|
||||
<ToggleButton value="script" icon={Code2} label="Scripts" size="md" {item} />
|
||||
{#if HOME_SEARCH_SHOW_FLOW}
|
||||
{#if !contentActive}
|
||||
<div class="flex justify-start">
|
||||
<ToggleButtonGroup
|
||||
selected={itemKind}
|
||||
onSelected={(v) => {
|
||||
// itemKind is derived from the shared filter object (which URL-syncs itself);
|
||||
// `all` clears the kind filter (delete, not null, so it doesn't linger as a
|
||||
// `kind: null` chip).
|
||||
if (v === 'all') {
|
||||
delete filterValues.val.kind
|
||||
} else {
|
||||
filterValues.val.kind = v
|
||||
subtab = v
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="all" label="All" size="md" {item} />
|
||||
<ToggleButton value="script" icon={Code2} label="Scripts" size="md" {item} />
|
||||
{#if HOME_SEARCH_SHOW_FLOW}
|
||||
<ToggleButton
|
||||
value="flow"
|
||||
label="Flows"
|
||||
icon={FlowIcon}
|
||||
selectedColor="#14b8a6"
|
||||
size="md"
|
||||
{item}
|
||||
/>
|
||||
{/if}
|
||||
<ToggleButton
|
||||
value="flow"
|
||||
label="Flows"
|
||||
icon={FlowIcon}
|
||||
selectedColor="#14b8a6"
|
||||
value="app"
|
||||
label="Apps"
|
||||
icon={LayoutDashboard}
|
||||
selectedColor="#fb923c"
|
||||
size="md"
|
||||
{item}
|
||||
/>
|
||||
{/if}
|
||||
<ToggleButton
|
||||
value="app"
|
||||
label="Apps"
|
||||
icon={LayoutDashboard}
|
||||
selectedColor="#fb923c"
|
||||
size="md"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<div class="relative text-primary grow min-w-[100px]">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<TextInput
|
||||
inputProps={{
|
||||
autofocus: true,
|
||||
placeholder: HOME_SEARCH_PLACEHOLDER,
|
||||
id: 'home-search-input'
|
||||
}}
|
||||
size="md"
|
||||
bind:value={filter}
|
||||
class="!pr-10"
|
||||
/>
|
||||
<button aria-label="Search" type="submit" class="absolute right-0 top-0 mt-2 mr-4">
|
||||
<svg
|
||||
class="h-4 w-4 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 56.966 56.966"
|
||||
style="enable-background:new 0 0 56.966 56.966;"
|
||||
xml:space="preserve"
|
||||
width="512px"
|
||||
height="512px"
|
||||
>
|
||||
<path
|
||||
d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
on:click={() => openSearchWithPrefilledText('#')}
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
endIcon={{
|
||||
icon: SearchCode
|
||||
}}
|
||||
>
|
||||
Content
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<ListFilters
|
||||
syncQuery
|
||||
bind:selectedFilter={ownerFilter}
|
||||
filters={owners}
|
||||
maxDisplayed={20}
|
||||
bottomMargin={false}
|
||||
/>
|
||||
{#if allLabels.length > 0}
|
||||
<div class="gap-1.5 w-full flex flex-wrap mt-2">
|
||||
{#each allLabels as label (label)}
|
||||
<Badge
|
||||
color="blue"
|
||||
small
|
||||
clickable
|
||||
selected={label === labelFilter}
|
||||
title="Label: {label}"
|
||||
onclick={() => {
|
||||
labelFilter = labelFilter === label ? undefined : label
|
||||
}}
|
||||
>
|
||||
<Tag size={10} class="inline -mt-px" />{label}
|
||||
{#if label === labelFilter}✗{/if}
|
||||
</Badge>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{/if}
|
||||
{#if filteredItems?.length == 0}
|
||||
<div class="mt-10"></div>
|
||||
{/if}
|
||||
{#if !loading}
|
||||
<div class="flex w-full flex-row-reverse gap-2 mt-2 mb-1 items-center h-6">
|
||||
<Popover floatingConfig={{ placement: 'bottom-end' }}>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
startIcon={{
|
||||
icon: ListFilterPlus
|
||||
}}
|
||||
nonCaptureEvent
|
||||
iconOnly
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="default"
|
||||
spacingSize="xs2"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="p-4">
|
||||
<span class="text-sm font-semibold text-emphasis">Filters</span>
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<Toggle size="xs" bind:checked={archived} options={{ right: 'Only archived' }} />
|
||||
{#if $userStore && !$userStore.operator}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={includeWithoutMain}
|
||||
options={{ right: 'Include library scripts' }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{#if filterUserFoldersType === 'only f/*'}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if filterUserFoldersType === 'u/username and f/*'}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore?.username} and f/*` }}
|
||||
|
||||
{#if !loading && !contentActive}
|
||||
<!-- List controls, between the kind toggle and the searchbar: select mode, tree
|
||||
view, expand/collapse (tree only), sort. -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#if homeSelection.available && !homeSelection.active}
|
||||
<Button
|
||||
startIcon={{ icon: CheckSquare }}
|
||||
iconOnly
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
title="Select items — move, archive, delete or discard several at once"
|
||||
on:click={() => homeSelection.enter()}
|
||||
/>
|
||||
{/if}
|
||||
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
|
||||
{#if treeView}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => (collapseAll = !collapseAll)}
|
||||
startIcon={{ icon: collapseAll ? ChevronsUpDown : ChevronsDownUp }}
|
||||
>
|
||||
{#if collapseAll}
|
||||
Expand all
|
||||
{:else}
|
||||
Collapse all
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
<DropdownV2
|
||||
items={sortItems}
|
||||
disabled={filter !== ''}
|
||||
@@ -1661,10 +1708,8 @@
|
||||
nonCaptureEvent
|
||||
disabled={filter !== ''}
|
||||
iconOnly={short === ''}
|
||||
size="xs"
|
||||
color="light"
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
spacingSize="xs2"
|
||||
startIcon={{ icon: ArrowDownUp }}
|
||||
title={filter !== ''
|
||||
? 'Sorting is disabled while searching (results are ranked by relevance)'
|
||||
@@ -1674,42 +1719,48 @@
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{#if treeView}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => (collapseAll = !collapseAll)}
|
||||
startIcon={{
|
||||
icon: collapseAll ? ChevronsUpDown : ChevronsDownUp
|
||||
}}
|
||||
>
|
||||
{#if collapseAll}
|
||||
Expand all
|
||||
{:else}
|
||||
Collapse all
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if homeSelection.available && !homeSelection.active}
|
||||
<!-- Last child of a flex-row-reverse row, so `mr-auto` absorbs the free
|
||||
space and pins it to the far left, away from the view/sort controls. -->
|
||||
<Button
|
||||
wrapperClasses="mr-auto"
|
||||
startIcon={{ icon: CheckSquare }}
|
||||
iconOnly
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="default"
|
||||
spacingSize="xs2"
|
||||
title="Select items — move, archive, delete or discard several at once"
|
||||
on:click={() => homeSelection.enter()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex grow items-center justify-end gap-2 min-w-0">
|
||||
<div class="relative text-primary w-full min-w-[200px] max-w-[26rem]">
|
||||
<FilterSearchbar
|
||||
schema={searchbarSchema}
|
||||
bind:value={filterValues.val}
|
||||
placeholder={HOME_SEARCH_PLACEHOLDER}
|
||||
presets={contentActive ? [] : searchPresets}
|
||||
autofocus
|
||||
hideDropdownOnFreeText
|
||||
inputId="home-search-input"
|
||||
onDropdownVisibleChange={(v) => (searchbarDropdownOpen = v)}
|
||||
/>
|
||||
</div>
|
||||
<!-- Same gate the old create actions used: hidden from operators and in workspaces
|
||||
whose direct-deploy protection cleared showEditButtons (NoDirectDeployAlert), since
|
||||
the menu itself does no permission check. -->
|
||||
{#if !$userStore?.operator && showEditButtons}
|
||||
<CreateActionsMenu />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{#if filteredItems == undefined || treeCountsPending}
|
||||
{#if filteredItems?.length == 0}
|
||||
<div class="mt-10"></div>
|
||||
{/if}
|
||||
<div class="mt-3">
|
||||
{#if filterValues.val.content}
|
||||
<!-- Content filter: swap the normal list/tree for the content-match view (the same one
|
||||
used by the Ctrl-K "Content" modal). It loads the workspace's scripts/flows/apps/
|
||||
resources and matches their contents client-side — usable on any instance. Keyed by
|
||||
workspace so a switch remounts a fresh instance and late in-flight responses from
|
||||
the previous workspace can't land in it. -->
|
||||
<!-- -mx-2 cancels ContentSearchInner's own px-2 so its rows line up flush with the
|
||||
runnable list instead of sitting slightly inset. -->
|
||||
<div class="-mx-2">
|
||||
{#key $workspaceStore}
|
||||
<ContentSearchInner bind:this={contentSearchEl} search={filterValues.val.content} />
|
||||
{/key}
|
||||
</div>
|
||||
{:else if filteredItems == undefined || treeCountsPending}
|
||||
<div class="mt-4"></div>
|
||||
<Skeleton layout={[[2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
{:else}
|
||||
<div class="flex justify-center items-center h-48">
|
||||
<div class="text-primary text-center">
|
||||
<div class="text-lg font-semibold text-emphasis">Welcome to Windmill</div>
|
||||
<div class="text-xs font-normal text-hint">
|
||||
Get started by creating your first script, flow, or app
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import TreeView from './TreeView.svelte'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
|
||||
|
||||
import { ChevronDown, ChevronUp, Folder, FolderTree, NetworkIcon, User } from 'lucide-svelte'
|
||||
import Item from './Item.svelte'
|
||||
@@ -297,120 +298,124 @@
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if opened || isSearching}
|
||||
<div>
|
||||
{#if hasPipeline && isFolder(item)}
|
||||
<!-- py-3 matches common/table/Row.svelte so this row sits at
|
||||
<!-- ResizeObserver, not a slide: a freshly-opened owner fetches its rows, so its height
|
||||
changes twice (open-empty, then rows land) and a slide would only animate the first. -->
|
||||
<ResizeTransitionWrapper vertical innerClass="w-full">
|
||||
{#if opened || isSearching}
|
||||
<div>
|
||||
{#if hasPipeline && isFolder(item)}
|
||||
<!-- py-3 matches common/table/Row.svelte so this row sits at
|
||||
the same height as the script/flow/app rows that follow
|
||||
it under the same folder; py-2 was visibly shorter. -->
|
||||
<a
|
||||
href="{base}/pipeline/{encodeURIComponent(item.folderName)}"
|
||||
class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
|
||||
<span class="text-xs font-medium text-emphasis">Pipeline</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#each item.items.slice(0, effectiveMax) as subItem, index ((subItem['path'] ? subItem['type'] + '__' + subItem['path'] + '__' + index : undefined) ?? 'folder__' + subItem['folderName'] + '__' + index)}
|
||||
<TreeView
|
||||
{isSearching}
|
||||
{collapseAll}
|
||||
item={subItem}
|
||||
{pipelineFolders}
|
||||
{ownerLoad}
|
||||
{onExpandOwner}
|
||||
{onCollapseOwner}
|
||||
parentPrefix={nodePrefix}
|
||||
ancestorHasMore={nodeHasMore}
|
||||
on:scriptChanged
|
||||
on:flowChanged
|
||||
on:appChanged
|
||||
on:rawAppChanged
|
||||
on:reload
|
||||
{showCode}
|
||||
{showEditButton}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
{/each}
|
||||
{#if effectiveMax < item.items.length}
|
||||
<div
|
||||
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
<!-- Rows, not items: this slices the node's own entries, where a subfolder
|
||||
is one row standing for everything under it. -->
|
||||
<span class="text-xs text-secondary">
|
||||
Showing {effectiveMax} of {item.items.length} loaded rows
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => {
|
||||
// Grown from what is rendered, not from showMax: the lazy ceiling can
|
||||
// already be showing more than showMax, and stepping that would take
|
||||
// several clicks to change anything on screen.
|
||||
showMax = Math.min(item.items.length, effectiveMax + showMoreStep)
|
||||
}}
|
||||
<a
|
||||
href="{base}/pipeline/{encodeURIComponent(item.folderName)}"
|
||||
class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if nodePrefix != undefined && ownerLoad != undefined}
|
||||
{#if nodeState?.loading && item.items.length === 0}
|
||||
<!-- Show the spinner only on the first load, when there's nothing yet. A
|
||||
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
|
||||
<span class="text-xs font-medium text-emphasis">Pipeline</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#each item.items.slice(0, effectiveMax) as subItem, index ((subItem['path'] ? subItem['type'] + '__' + subItem['path'] + '__' + index : undefined) ?? 'folder__' + subItem['folderName'] + '__' + index)}
|
||||
<TreeView
|
||||
{isSearching}
|
||||
{collapseAll}
|
||||
item={subItem}
|
||||
{pipelineFolders}
|
||||
{ownerLoad}
|
||||
{onExpandOwner}
|
||||
{onCollapseOwner}
|
||||
parentPrefix={nodePrefix}
|
||||
ancestorHasMore={nodeHasMore}
|
||||
on:scriptChanged
|
||||
on:flowChanged
|
||||
on:appChanged
|
||||
on:rawAppChanged
|
||||
on:reload
|
||||
{showCode}
|
||||
{showEditButton}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
{/each}
|
||||
{#if effectiveMax < item.items.length}
|
||||
<div
|
||||
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
<!-- Rows, not items: this slices the node's own entries, where a subfolder
|
||||
is one row standing for everything under it. -->
|
||||
<span class="text-xs text-secondary">
|
||||
Showing {effectiveMax} of {item.items.length} loaded rows
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => {
|
||||
// Grown from what is rendered, not from showMax: the lazy ceiling can
|
||||
// already be showing more than showMax, and stepping that would take
|
||||
// several clicks to change anything on screen.
|
||||
showMax = Math.min(item.items.length, effectiveMax + showMoreStep)
|
||||
}}
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if nodePrefix != undefined && ownerLoad != undefined}
|
||||
{#if nodeState?.loading && item.items.length === 0}
|
||||
<!-- Show the spinner only on the first load, when there's nothing yet. A
|
||||
re-sort/re-filter re-fetch keeps the old rows visible and swaps them
|
||||
in place, so flashing "Loading…" under them would just be noise. -->
|
||||
<div class="text-center text-xs py-2 text-secondary">Loading…</div>
|
||||
{:else if nodeHasMore && (collapseAll || nodeState?.loading || effectiveMax >= item.items.length)}
|
||||
<!-- Every folder pages within its own prefix, so completing a subfolder
|
||||
<div class="text-center text-xs py-2 text-secondary">Loading…</div>
|
||||
{:else if nodeHasMore && (collapseAll || nodeState?.loading || effectiveMax >= item.items.length)}
|
||||
<!-- Every folder pages within its own prefix, so completing a subfolder
|
||||
doesn't mean paging everything its owner holds. Under "expand all" this
|
||||
waits for the client "Show more" above, so the two pagers don't stack
|
||||
under every open node at once — but never while loading, or a long run
|
||||
would unmount its own spinner on its first page. Spelling out the counts
|
||||
is the point: without them this reads as an optional extra rather than
|
||||
as rows still missing. -->
|
||||
<div
|
||||
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
<span class="text-xs text-secondary">
|
||||
Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-2 shrink-0">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
loading={nodeState?.loading && !loadingAll}
|
||||
disabled={nodeState?.loading}
|
||||
on:click={() =>
|
||||
nodePrefix != undefined &&
|
||||
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false)}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
<!-- Same call, paged to the end: a folder several pages deep otherwise
|
||||
<div
|
||||
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
<span class="text-xs text-secondary">
|
||||
Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-2 shrink-0">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
loading={nodeState?.loading && !loadingAll}
|
||||
disabled={nodeState?.loading}
|
||||
on:click={() =>
|
||||
nodePrefix != undefined &&
|
||||
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false)}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
<!-- Same call, paged to the end: a folder several pages deep otherwise
|
||||
takes a click per page to reach an exact count. -->
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
loading={nodeState?.loading && loadingAll}
|
||||
disabled={nodeState?.loading}
|
||||
on:click={() => {
|
||||
if (nodePrefix == undefined) return
|
||||
loadingAll = true
|
||||
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false, { all: true })
|
||||
}}
|
||||
>
|
||||
Load all
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
loading={nodeState?.loading && loadingAll}
|
||||
disabled={nodeState?.loading}
|
||||
on:click={() => {
|
||||
if (nodePrefix == undefined) return
|
||||
loadingAll = true
|
||||
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false, { all: true })
|
||||
}}
|
||||
>
|
||||
Load all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</ResizeTransitionWrapper>
|
||||
</div>
|
||||
{:else}
|
||||
<Item
|
||||
|
||||
@@ -217,7 +217,9 @@ export function buildRunsFilterSearchbarSchema({
|
||||
show_future_jobs: {
|
||||
type: 'boolean' as const,
|
||||
label: 'Show future jobs (Default: true)',
|
||||
description: 'Include jobs that are planned later'
|
||||
description: 'Include jobs that are planned later',
|
||||
// On by default (useJobsLoader), so selecting it means "turn it off" — keep the picker.
|
||||
default: true
|
||||
},
|
||||
...(isSuperAdminOrDevops &&
|
||||
isAdminsWorkspace && {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
class: className = '',
|
||||
innerClass = '',
|
||||
maxHeight = 256,
|
||||
instantClose = false,
|
||||
getInputRect,
|
||||
children
|
||||
}: {
|
||||
@@ -26,6 +27,9 @@
|
||||
class?: string
|
||||
innerClass?: string
|
||||
maxHeight?: number
|
||||
// Close with no height-collapse animation (still animates open). Used where the
|
||||
// dropdown is toggled off as the user types, so the panel vanishes at once.
|
||||
instantClose?: boolean
|
||||
getInputRect?: () => DOMRect
|
||||
children?: Snippet
|
||||
} = $props()
|
||||
@@ -71,7 +75,11 @@
|
||||
// We do not use Svelte transitions because they can not animate in the opposite direction
|
||||
// when the dropdown is opens above the input
|
||||
// Also CSS transitions are smoother because they do not rely on JS / animation frames
|
||||
let uiState = $state({ domExists: untrack(() => open), visible: untrack(() => open), timeout: null as number | null })
|
||||
let uiState = $state({
|
||||
domExists: untrack(() => open),
|
||||
visible: untrack(() => open),
|
||||
timeout: null as number | null
|
||||
})
|
||||
let initial = true
|
||||
watch(
|
||||
() => open && !disabled,
|
||||
@@ -81,7 +89,10 @@
|
||||
initial = false
|
||||
return
|
||||
}
|
||||
if (reducedMotion.val) {
|
||||
// Reduced motion skips all animation; instantClose skips only the closing
|
||||
// one (open still animates) so the panel disappears the instant it's toggled off.
|
||||
if (reducedMotion.val || (instantClose && !isOpen)) {
|
||||
if (uiState.timeout) clearTimeout(uiState.timeout)
|
||||
uiState = {
|
||||
domExists: open && !disabled,
|
||||
visible: open && !disabled,
|
||||
|
||||
@@ -83,7 +83,11 @@ export async function openEditorInSession(
|
||||
previewParams?: Record<string, string>,
|
||||
opts?: { seedPrompt?: string; autoSend?: boolean }
|
||||
): Promise<void> {
|
||||
await openInSession(withPreviewParams(sessionTargetHref(target), previewParams), workspaceId, opts)
|
||||
await openInSession(
|
||||
withPreviewParams(sessionTargetHref(target), previewParams),
|
||||
workspaceId,
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
// Open a fresh AI session showing a workspace page (Runs, a trigger list) in its
|
||||
|
||||
@@ -5,22 +5,12 @@
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import FlowIcon from '$lib/components/home/FlowIcon.svelte'
|
||||
import CreateActionsMenu from '$lib/components/home/CreateActionsMenu.svelte'
|
||||
import { getScriptByPath } from '$lib/scripts'
|
||||
import type { HubItem } from '$lib/components/flows/pickers/model'
|
||||
import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte'
|
||||
import PickHubFlow from '$lib/components/flows/pickers/PickHubFlow.svelte'
|
||||
import HighlightCode from '$lib/components/HighlightCode.svelte'
|
||||
import HomeConnectDrawer from '$lib/components/home/HomeConnectDrawer.svelte'
|
||||
import {
|
||||
ExternalLink,
|
||||
GitFork,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Code,
|
||||
LayoutDashboard,
|
||||
PlugZap
|
||||
} from 'lucide-svelte'
|
||||
import { ExternalLink, GitFork, Globe2, Loader2, Code, LayoutDashboard } from 'lucide-svelte'
|
||||
import { hubBaseUrlStore } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
@@ -28,7 +18,6 @@
|
||||
import PickHubApp from '$lib/components/flows/pickers/PickHubApp.svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import type { EditorBreakpoint } from '$lib/components/apps/types'
|
||||
import { HOME_SHOW_HUB } from '$lib/consts'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { page } from '$app/state'
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
@@ -42,6 +31,8 @@
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||||
import { z } from 'zod'
|
||||
import HomeAIChat from '$lib/components/home/HomeAIChat.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
|
||||
type Tab = 'hub' | 'workspace'
|
||||
|
||||
@@ -97,7 +88,6 @@
|
||||
}
|
||||
|
||||
let workspaceTutorials: WorkspaceTutorials | undefined = $state(undefined)
|
||||
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
|
||||
|
||||
// Provide workspaceTutorials to child components via a reactive wrapper
|
||||
let workspaceTutorialsContext = $derived(workspaceTutorials)
|
||||
@@ -272,49 +262,23 @@
|
||||
>
|
||||
<ForkWorkspaceBanner />
|
||||
<WorkspaceDraftsBanner />
|
||||
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full">
|
||||
{#if $workspaceStore == 'admins'}
|
||||
<div class="my-4"></div>
|
||||
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full mb-6">
|
||||
<!-- HomeAIChat carries both the AI composer and the AI-independent CLI/MCP connect row,
|
||||
so it shows whenever the sessions beta is on; the composer itself is gated on operator
|
||||
status inside the component (operators are refused by /sessions). -->
|
||||
{#if isGlobalAiEnabled()}
|
||||
<div class="w-full mb-16 mt-2">
|
||||
<HomeAIChat />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $workspaceStore == 'admins'}
|
||||
<Alert title="Admins workspace">
|
||||
The Admins workspace is for admins only and contains scripts whose purpose is to manage your
|
||||
Windmill instance, such as keeping resource types up to date.
|
||||
</Alert>
|
||||
<div class="my-4"></div>
|
||||
{/if}
|
||||
<div class="flex flex-row flex-wrap justify-between items-center gap-3 pb-2 my-4 mr-2 min-h-16">
|
||||
<h1 class="text-2xl font-semibold text-emphasis whitespace-nowrap leading-6 tracking-tight">
|
||||
Home
|
||||
</h1>
|
||||
<div class="ml-auto flex flex-row gap-2 items-center">
|
||||
{#if !$userStore?.operator && HOME_SHOW_HUB}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Globe2 }}
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
href={$hubBaseUrlStore}
|
||||
target="_blank"
|
||||
btnClasses="whitespace-nowrap"
|
||||
>
|
||||
Hub
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: PlugZap }}
|
||||
btnClasses="whitespace-nowrap"
|
||||
onClick={() => homeConnectDrawer?.openDrawer?.()}
|
||||
>
|
||||
CLI / MCP
|
||||
</Button>
|
||||
{#if !$userStore?.operator && showCreateButtons}
|
||||
<div class="ml-2">
|
||||
<CreateActionsMenu />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TutorialBanner />
|
||||
|
||||
@@ -396,9 +360,8 @@
|
||||
</div>
|
||||
|
||||
{#if tab == 'workspace'}
|
||||
<ItemsList bind:filter={getFilter, setFilter} bind:subtab showEditButtons={showCreateButtons} />
|
||||
<ItemsList bind:subtab showEditButtons={showCreateButtons} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<WorkspaceTutorials bind:this={workspaceTutorials} />
|
||||
<HomeConnectDrawer bind:this={homeConnectDrawer} />
|
||||
|
||||
Reference in New Issue
Block a user