mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 00:01:34 +00:00
[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>
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT cost_nanos FROM ai_free_token_usage\n WHERE email = $1 AND day = (now() at time zone 'utc')::date",
|
||||
"query": "SELECT cost_nanos FROM ai_free_token_usage WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -18,5 +18,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "445483e44858098da49a2e8659cf2a2e9d86ef70d0e667f0e7b291dde879608e"
|
||||
"hash": "247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_free_token_usage (email, day, cost_nanos, updated_at)\n VALUES ($1, (now() at time zone 'utc')::date, $2, now())\n ON CONFLICT (email, day) DO UPDATE\n SET cost_nanos = ai_free_token_usage.cost_nanos + EXCLUDED.cost_nanos,\n updated_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5d4138a0d49fe5f279c4d63c7ae0d90736fda75fd059254b7a105b51083a4416"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ((now() at time zone 'utc')::date, $1, now())\n ON CONFLICT (day) DO UPDATE\n SET cost_nanos = ai_free_token_daily_usage.cost_nanos + EXCLUDED.cost_nanos,\n updated_at = now()",
|
||||
"query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ((now() at time zone 'utc')::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": {
|
||||
@@ -10,5 +10,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b3f8dc3254e15c5818ae26874c4754b11e357cad32b41ca30ce2a46f89fa8be2"
|
||||
"hash": "60b9618eb975a257fcdb6c310ac8de2ffaf98a111c5dd0d612f4bfa8bb00e5c4"
|
||||
}
|
||||
+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 @@
|
||||
583a5c32eb9e04a7bb9a2a99cf7fb6cd4fe9f7f9
|
||||
593ad8e171478758e95785f91c5d9548e09957bf
|
||||
@@ -1,18 +1,17 @@
|
||||
-- Per-user DAILY usage 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. Keyed by
|
||||
-- normalized email so the allowance is shared across a user's workspaces (and is
|
||||
-- resistant to +tag / gmail-dot aliasing).
|
||||
-- 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) NOT NULL,
|
||||
day DATE NOT NULL,
|
||||
email VARCHAR(255) PRIMARY KEY,
|
||||
cost_nanos BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (email, day)
|
||||
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 budget. One row per UTC day.
|
||||
-- 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,
|
||||
|
||||
@@ -38,7 +38,7 @@ account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), clien
|
||||
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), day(date), cost_nanos(bigint), updated_at(ts)
|
||||
ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts)
|
||||
alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(bool), workspace_id(text), acknowledged_workspace(bool), resource(text)
|
||||
app: id(bigint), workspace_id(char), path(char), summary(char), policy(jsonb), versions(bigint[]), extra_perms(jsonb), draft_only(bool), custom_path(text), labels(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
|
||||
@@ -23524,6 +23524,24 @@ components:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 2000000
|
||||
free_tier:
|
||||
$ref: "#/components/schemas/FreeTierInfo"
|
||||
|
||||
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
|
||||
|
||||
InstanceAIProviderSummary:
|
||||
type: object
|
||||
|
||||
@@ -365,6 +365,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")]
|
||||
@@ -379,6 +392,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>,
|
||||
}
|
||||
|
||||
impl AIConfig {
|
||||
|
||||
@@ -205,8 +205,9 @@ async fn get_copilot_info(
|
||||
} else if let Some(free_config) =
|
||||
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
|
||||
{
|
||||
// Nothing configured: surface the free Claude Opus tier (EE-only) when it is
|
||||
// available to this user.
|
||||
// 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()))
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { writable, get } from 'svelte/store'
|
||||
import { workspaceAIClients } from './components/copilot/lib'
|
||||
import { type AIProviderModel, type AIProvider, WorkspaceService, type AIConfig } from './gen'
|
||||
import {
|
||||
type AIProviderModel,
|
||||
type AIProvider,
|
||||
WorkspaceService,
|
||||
type AIConfig,
|
||||
type FreeTierInfo
|
||||
} from './gen'
|
||||
import {
|
||||
aiUserDisabled,
|
||||
COPILOT_SESSION_MODEL_SETTING_NAME,
|
||||
@@ -39,6 +45,10 @@ export const copilotInfo = writable<{
|
||||
customPrompts?: Record<string, string>
|
||||
maxTokensPerModel?: Record<string, number>
|
||||
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,
|
||||
@@ -142,7 +152,8 @@ export function setCopilotInfo(aiConfig: AIConfig) {
|
||||
aiModels: aiModels,
|
||||
customPrompts: aiConfig.custom_prompts ?? {},
|
||||
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {},
|
||||
webSearchEnabledProviders
|
||||
webSearchEnabledProviders,
|
||||
freeTier: aiConfig.free_tier
|
||||
})
|
||||
} else {
|
||||
copilotSessionModel.set(undefined)
|
||||
@@ -155,7 +166,10 @@ export function setCopilotInfo(aiConfig: AIConfig) {
|
||||
aiModels: [],
|
||||
customPrompts: {},
|
||||
maxTokensPerModel: {},
|
||||
webSearchEnabledProviders: {}
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
Hand,
|
||||
HistoryIcon,
|
||||
Hourglass,
|
||||
KeyRound,
|
||||
MousePointer2,
|
||||
Plus,
|
||||
TextSelect,
|
||||
@@ -52,9 +53,16 @@
|
||||
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)
|
||||
// `label` is shown in the dropdown; `shortLabel` (when set) is shown in the
|
||||
// compact trigger pill to save horizontal space.
|
||||
type AutonomyModeOption = { label: string; shortLabel?: string; mode: AIAutonomyMode }
|
||||
@@ -479,6 +487,27 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet freeTierExhaustedBanner()}
|
||||
<div class="my-2">
|
||||
<Alert type="error" 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
|
||||
size="xs2"
|
||||
variant="default"
|
||||
color="red"
|
||||
startIcon={{ icon: KeyRound }}
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
>
|
||||
Add your own API key
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
</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
|
||||
@@ -588,6 +617,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}
|
||||
@@ -613,6 +647,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(
|
||||
|
||||
@@ -64,6 +64,7 @@ import { untrack } from 'svelte'
|
||||
import { get } from 'svelte/store'
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { workspaceStore, type DBSchemas } from '$lib/stores'
|
||||
import { copilotInfo, loadCopilot } from '$lib/aiStore'
|
||||
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
|
||||
import { readDocsPageTool, searchDocsTool } from './docs/core'
|
||||
import { TypewriterReveal } from './typewriterReveal'
|
||||
@@ -267,6 +268,29 @@ function getSendRequestErrorMessage(err: unknown, webSearchUnavailable: boolean)
|
||||
return appendWebSearchErrorHint(message, webSearchUnavailable)
|
||||
}
|
||||
|
||||
/**
|
||||
* A free-tier user can exhaust their grant mid-session: the request that spends the last
|
||||
* of it succeeds, and the NEXT one is refused by the proxy. copilotInfo is only fetched on
|
||||
* workspace load, so without this the client would keep believing it has budget until a
|
||||
* page reload — the user would see only a toast, never the banner. Re-fetching the config
|
||||
* after a failed send lets the server tell us the grant is gone, which flips
|
||||
* `freeTier.exhausted` and reveals the banner.
|
||||
*
|
||||
* Scoped to users actually on the free tier and not already flagged, so an ordinary AI
|
||||
* error (rate limit, network) costs no extra request. Deliberately keyed on that state
|
||||
* rather than on matching the error text, which would break the moment the copy changes.
|
||||
*/
|
||||
async function refreshFreeTierStateAfterError(workspace: string | undefined) {
|
||||
if (!workspace) return
|
||||
const info = get(copilotInfo)
|
||||
if (!info.freeTier || info.freeTier.exhausted) return
|
||||
try {
|
||||
await loadCopilot(workspace)
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh copilot info after AI error', err)
|
||||
}
|
||||
}
|
||||
|
||||
export class AIChatManager {
|
||||
contextManager = new ContextManager()
|
||||
historyManager = new HistoryManager()
|
||||
@@ -2395,6 +2419,7 @@ export class AIChatManager {
|
||||
this.flagLastMessageAsError()
|
||||
}
|
||||
sendUserToast(getSendRequestErrorMessage(err, webSearchUnavailable), true)
|
||||
await refreshFreeTierStateAfterError(this.operatingWorkspace)
|
||||
} finally {
|
||||
this.loading = false
|
||||
// Turn teardown: cancel any in-flight reveal frame and drop leftover
|
||||
|
||||
@@ -45,6 +45,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)
|
||||
)
|
||||
@@ -311,6 +318,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>
|
||||
@@ -377,6 +391,42 @@
|
||||
<div class="text-2xs text-tertiary mt-0.5">Not supported by this model</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if freeTier}
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
<!-- Not a melt item: informational, so keyboard navigation skips it. -->
|
||||
<div class="px-3 pt-1 pb-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-2xs uppercase tracking-wide text-secondary">Free Windmill AI</span>
|
||||
<span
|
||||
class="text-2xs tabular-nums {freeRunningLow ? 'text-yellow-600' : 'text-secondary'}"
|
||||
>{freeUsedPct}% used</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1 w-full rounded-full bg-surface-secondary overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full {freeRunningLow
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-surface-accent-primary'}"
|
||||
style="width: {freeUsedPct}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-1 text-2xs text-tertiary">
|
||||
{freeTier.exhausted
|
||||
? 'Your free tokens are used up. Add your own API key to keep using AI.'
|
||||
: 'One-time allowance. Add your own API key for unlimited use.'}
|
||||
</div>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href={AI_SETTINGS_HREF}
|
||||
target="_blank"
|
||||
class="mt-1 inline-block text-2xs text-secondary hover:underline"
|
||||
>
|
||||
Add your own API key
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { ArrowUp, ExternalLink, Globe2, PlugZap, Settings } from 'lucide-svelte'
|
||||
import { ArrowUp, ExternalLink, Globe2, KeyRound, PlugZap, Settings } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { startSessionWithPrompt } from '../sessions/sessionSwitch.svelte'
|
||||
import { copilotInfo, copilotWorkspace, loadCopilot } from '$lib/aiStore'
|
||||
@@ -48,6 +48,10 @@
|
||||
// (unloaded) state doesn't flash the overlay while a provider is configured.
|
||||
let disabled = $derived($copilotWorkspace === $workspaceStore && !$copilotInfo.enabled)
|
||||
|
||||
// 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)
|
||||
|
||||
let starting = $state(false)
|
||||
async function start() {
|
||||
if (disabled || starting || !value.trim()) return
|
||||
@@ -112,12 +116,12 @@
|
||||
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="max-w-[40rem] grow relative group">
|
||||
<p class="text-center font-regular text-3xl mb-4">Build with AI</p>
|
||||
<div
|
||||
class={disabled
|
||||
? 'transition-[filter] group-hover:blur-sm pointer-events-none select-none'
|
||||
: ''}
|
||||
>
|
||||
<p class="text-center font-regular text-3xl mb-4">Build with AI</p>
|
||||
<!-- 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">
|
||||
@@ -185,14 +189,18 @@
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-md bg-surface/70 opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
>
|
||||
<p class="text-sm text-secondary">No AI provider is configured</p>
|
||||
<p class="text-sm text-secondary">
|
||||
{freeTierExhausted
|
||||
? 'You have used all of your free Windmill AI tokens'
|
||||
: 'No AI provider is configured'}
|
||||
</p>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Settings }}
|
||||
startIcon={{ icon: freeTierExhausted ? KeyRound : Settings }}
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
>
|
||||
Configure AI
|
||||
{freeTierExhausted ? 'Add your own API key' : 'Configure AI'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user