diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8ec2faa..403b3a3 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -6,7 +6,7 @@ publish = false [lib] name = "cursor_byok_desktop" -crate-type = ["staticlib", "cdylib", "rlib"] +crate-type = ["rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/apps/desktop/src/features/plugins/PluginManagementPage.module.scss b/apps/desktop/src/features/plugins/PluginManagementPage.module.scss index 10a3e0b..d17ef3b 100644 --- a/apps/desktop/src/features/plugins/PluginManagementPage.module.scss +++ b/apps/desktop/src/features/plugins/PluginManagementPage.module.scss @@ -99,11 +99,13 @@ display: flex; align-items: center; flex-wrap: nowrap; - gap: 7px; + gap: 6px; > button { min-width: 0; flex: 0 1 auto; + padding-left: 8px; + padding-right: 8px; } } diff --git a/apps/desktop/src/features/plugins/PluginManagementPage.tsx b/apps/desktop/src/features/plugins/PluginManagementPage.tsx index a1ca992..11c8cb7 100644 --- a/apps/desktop/src/features/plugins/PluginManagementPage.tsx +++ b/apps/desktop/src/features/plugins/PluginManagementPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api"; +import { api, getDisabledPluginModelIds, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api"; import { useI18n } from "../../i18n/store"; import { PageContent } from "../../shell/layout/PageContent"; import { appStore, useAppStore } from "../../shared/store/appStore"; @@ -138,7 +138,17 @@ function PluginCard({ plugin, onOpen }: { const [importing, setImporting] = useState(false); const configured = plugin.providers.some((provider) => provider.configured); const accountCount = plugin.resources.reduce((count, resource) => count + resource.resources.length, 0); - const modelCount = plugin.providers.reduce((count, provider) => count + provider.models.length, 0); + const [disabledModelIds, setDisabledModelIds] = useState>(() => getDisabledPluginModelIds()); + useEffect(() => { + const handleUpdate = () => setDisabledModelIds(getDisabledPluginModelIds()); + window.addEventListener("cursor_plugin_models_changed", handleUpdate); + return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate); + }, []); + + const modelCount = plugin.providers.reduce( + (count, provider) => count + provider.models.filter((m) => !disabledModelIds.has(m.id)).length, + 0, + ); const subtitle = plugin.providers.map((provider) => pluginText(provider.displayName, locale)).join(" · ") || plugin.id; const importResource = plugin.resources.find((resource) => resource.import); const exportResource = plugin.resources.find((resource) => resource.resources.length > 0); diff --git a/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss b/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss index 23d1fb2..6f2c965 100644 --- a/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss +++ b/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss @@ -12,7 +12,7 @@ gap: 10px; padding: 16px; - > span { + >span { color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; } @@ -37,6 +37,10 @@ } button { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; padding: 6px 10px; color: var(--vscode-foreground); background: var(--vscode-textCodeBlock-background); @@ -97,15 +101,21 @@ gap: 8px; } -.providerRow, -.resourceRow { +.providerCard { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +.providerRow { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; - > div:first-child { + >div:first-child { min-width: 0; display: flex; flex-direction: column; @@ -118,6 +128,576 @@ } } +.resourceRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + + .resourceText { + display: flex; + flex-direction: column; + gap: 4px; + + .resourceHeaderLine { + display: flex; + align-items: center; + gap: 8px; + } + + .resourceDescription { + color: var(--vscode-descriptionForeground); + font-size: type.$font-size-xs; + } + } +} + +.metricsRow { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.metricBadge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + line-height: 1.2; + transition: all 0.15s ease; + + .metricPrefix { + font-weight: 700; + opacity: 0.9; + } + + .metricValue { + font-variant-numeric: tabular-nums; + } +} + +.claudeMetricBadge { + background: rgba(234, 88, 12, 0.14); + color: #fb923c; + border: 1px solid rgba(234, 88, 12, 0.28); +} + +.geminiMetricBadge { + background: rgba(59, 130, 246, 0.14); + color: #60a5fa; + border: 1px solid rgba(59, 130, 246, 0.28); +} + +.genericMetricBadge { + background: color-mix(in srgb, var(--vscode-badge-background, #333) 40%, transparent); + color: var(--vscode-badge-foreground, #ccc); + border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border, #444) 40%, transparent); +} + +.proBadge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 6px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + border-radius: 4px; + background: linear-gradient(135deg, rgba(234, 88, 12, 0.25), rgba(249, 115, 22, 0.15)); + color: #fb923c; + border: 1px solid rgba(249, 115, 22, 0.4); + box-shadow: 0 0 6px rgba(249, 115, 22, 0.15); +} + +.freeBadge { + display: inline-flex; + align-items: center; + padding: 1px 5px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + border-radius: 4px; + background: color-mix(in srgb, var(--vscode-badge-background, #333) 30%, transparent); + color: var(--vscode-descriptionForeground, #888); + border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border, #444) 40%, transparent); +} + +.providerActions { + display: flex; + align-items: center; + gap: 8px; +} + +.modelsView { + display: flex; + flex-direction: column; + gap: 12px; +} + +.modelsViewHeader { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 2px; + + .backButton { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + color: var(--vscode-descriptionForeground); + background: transparent; + border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 60%, transparent); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: var(--vscode-foreground); + background: color-mix(in srgb, var(--vscode-foreground) 8%, transparent); + border-color: var(--vscode-focusBorder, #007fd4); + } + } + + .headerTitle { + display: flex; + align-items: center; + gap: 10px; + + strong { + color: var(--vscode-foreground); + font-size: type.$font-size-base; + font-weight: 600; + } + + span { + color: var(--vscode-descriptionForeground); + font-size: type.$font-size-xs; + } + + .headerCountChip { + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: var(--vscode-focusBorder, #007fd4); + background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 30%, transparent); + } + } +} + +.modelListToolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.searchContainer { + position: relative; + flex: 1 1 auto; + min-width: 0; + + .searchIcon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--vscode-descriptionForeground); + pointer-events: none; + } + + .clearSearchBtn { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + color: var(--vscode-descriptionForeground); + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); + border: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + color: var(--vscode-foreground); + background: color-mix(in srgb, var(--vscode-foreground) 20%, transparent); + } + } +} + +.modelSearchInput { + width: 100%; + padding: 7px 30px 7px 32px; + font-size: type.$font-size-xs; + color: var(--vscode-foreground); + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border)); + border-radius: 6px; + outline: none; + transition: all 0.2s ease; + box-sizing: border-box; + + &::placeholder { + color: color-mix(in srgb, var(--vscode-descriptionForeground) 70%, transparent); + } + + &:focus { + border-color: var(--vscode-focusBorder, #007fd4); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 20%, transparent); + } +} + +.modelBatchActions { + display: flex; + align-items: center; + gap: 8px; + + .pillButton { + padding: 5px 12px; + font-size: type.$font-size-xs; + font-weight: 500; + color: var(--vscode-foreground); + background: color-mix(in srgb, var(--vscode-button-secondaryBackground, #3a3d41) 60%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 80%, transparent); + border-radius: 999px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + color: var(--vscode-button-foreground, #fff); + background: var(--vscode-button-secondaryHoverBackground, #45494e); + border-color: color-mix(in srgb, var(--vscode-button-secondaryHoverBackground, #45494e) 50%, transparent); + } + } +} + +.modelsViewList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.emptySearch { + padding: 32px 16px; + text-align: center; + color: var(--vscode-descriptionForeground); + font-size: type.$font-size-xs; +} + +.modelItem { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 14px; + background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, var(--vscode-editor-background)); + border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 70%, transparent); + border-radius: 8px; + font-size: type.$font-size-xs; + cursor: pointer; + user-select: none; + position: relative; + transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 50%, transparent); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.18); + background: color-mix(in srgb, var(--vscode-sideBar-background) 80%, var(--vscode-focusBorder, #007fd4) 6%); + } +} + +.modelItemActive { + border-color: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 55%, transparent); + background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 7%, var(--vscode-editor-background)); +} + +.modelItemDisabled { + opacity: 0.45; + background: color-mix(in srgb, var(--vscode-editor-background) 80%, transparent); + border-color: color-mix(in srgb, var(--vscode-sideBar-border) 40%, transparent); + + &:hover { + opacity: 0.8; + transform: none; + } +} + +.modelCheckbox { + flex: 0 0 auto; + width: 16px; + height: 16px; + margin: 0; + cursor: pointer; + accent-color: var(--vscode-focusBorder, #007fd4); + transition: transform 0.15s ease; + + &:hover { + transform: scale(1.1); + } +} + +.modelInfo { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.modelName { + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelId { + font-family: var(--vscode-editor-font-family); + color: var(--vscode-descriptionForeground); + font-size: type.$font-size-2xs; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelBadges { + flex: 0 0 auto; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.claudeTag, +.geminiTag, +.gptTag, +.thinkingTag, +.highTag, +.mediumTag, +.lowTag, +.extraLowTag, +.imageTag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.2px; + line-height: 1.3; +} + +.claudeTag { + color: #f59e0b; + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.35); +} + +.geminiTag { + color: #60a5fa; + background: rgba(96, 165, 250, 0.12); + border: 1px solid rgba(96, 165, 250, 0.35); +} + +.gptTag { + color: #34d399; + background: rgba(52, 211, 153, 0.12); + border: 1px solid rgba(52, 211, 153, 0.35); +} + +.thinkingTag { + color: #c084fc; + background: rgba(192, 132, 252, 0.12); + border: 1px solid rgba(192, 132, 252, 0.35); +} + +.highTag { + color: #f87171; + background: rgba(248, 113, 113, 0.12); + border: 1px solid rgba(248, 113, 113, 0.35); +} + +.mediumTag { + color: #fbbf24; + background: rgba(251, 191, 36, 0.12); + border: 1px solid rgba(251, 191, 36, 0.35); +} + +.lowTag { + color: #2dd4bf; + background: rgba(45, 212, 191, 0.12); + border: 1px solid rgba(45, 212, 191, 0.35); +} + +.extraLowTag { + color: #38bdf8; + background: rgba(56, 189, 248, 0.12); + border: 1px solid rgba(56, 189, 248, 0.35); +} + +.imageTag { + color: #f472b6; + background: rgba(244, 114, 182, 0.12); + border: 1px solid rgba(244, 114, 182, 0.35); +} + +.resourceIdentity { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.resourceText { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.resourceRowDisabled { + opacity: 0.6; +} + +.resourceActions { + display: flex; + align-items: center; + gap: 8px; +} + +.actionIconButton { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + + color: var(--vscode-textLink-foreground, #3b82f6); + background: color-mix(in srgb, var(--vscode-textLink-foreground, #3b82f6) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-textLink-foreground, #3b82f6) 30%, transparent); + + &:hover:not(:disabled) { + color: #fff; + background: var(--vscode-textLink-foreground, #3b82f6); + border-color: var(--vscode-textLink-foreground, #3b82f6); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.refreshingSpin { + animation: spin 0.8s linear infinite; + opacity: 0.7; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.actionDeleteButton { + color: #ef4444; + background: color-mix(in srgb, #ef4444 10%, transparent); + border: 1px solid color-mix(in srgb, #ef4444 30%, transparent); + + &:hover:not(:disabled) { + color: #fff; + background: #ef4444; + border-color: #ef4444; + } +} + +.readyBadge, +.coolingBadge, +.invalidBadge, +.disabledBadge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 999px; + font-size: type.$font-size-2xs; + font-weight: 500; + letter-spacing: 0.1px; +} + +.badgeDot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.readyBadge { + color: var(--vscode-testing-iconPassed, #73c991); + background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 30%, transparent); + + .badgeDot { + background: var(--vscode-testing-iconPassed, #73c991); + box-shadow: 0 0 6px color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 60%, transparent); + } +} + +.coolingBadge { + color: var(--vscode-editorWarning-foreground, #cca700); + background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 30%, transparent); + + .badgeDot { + background: var(--vscode-editorWarning-foreground, #cca700); + } +} + +.invalidBadge { + color: var(--vscode-errorForeground, #f48771); + background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-errorForeground, #f48771) 30%, transparent); + + .badgeDot { + background: var(--vscode-errorForeground, #f48771); + } +} + +.disabledBadge { + color: var(--vscode-descriptionForeground); + background: color-mix(in srgb, var(--vscode-descriptionForeground) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 80%, transparent); + + .badgeDot { + background: var(--vscode-descriptionForeground); + } +} + .ready { color: var(--vscode-testing-iconPassed, #73c991) !important; } @@ -153,4 +733,4 @@ .error { color: var(--vscode-errorForeground, #f48771); font-size: type.$font-size-xs; -} +} \ No newline at end of file diff --git a/apps/desktop/src/features/plugins/PluginResourcePanels.tsx b/apps/desktop/src/features/plugins/PluginResourcePanels.tsx index 2c4f902..09b3711 100644 --- a/apps/desktop/src/features/plugins/PluginResourcePanels.tsx +++ b/apps/desktop/src/features/plugins/PluginResourcePanels.tsx @@ -1,6 +1,11 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { api, + getDisabledPluginAccountIds, + getDisabledPluginModelIds, + setPluginAccountEnabled, + setPluginModelEnabled, + setMultiplePluginModelsEnabled, pluginText, type PluginAddMethod, type PluginDescriptor, @@ -14,6 +19,10 @@ import { appStore } from "../../shared/store/appStore"; import { Button } from "../../shared/ui/Button"; import { Card } from "../../shared/ui/Card"; import { FormField, TextInput } from "../../shared/ui/FormControls"; +import { Icon } from "../../shared/ui/Icon"; +import { refreshIcon, trashIcon } from "../../shared/ui/icons"; +import { Switch } from "../../shared/ui/Switch"; +import { TooltipTrigger } from "../../shared/ui/TooltipTrigger"; import styles from "./PluginResourcePanels.module.scss"; const PAGE_SIZE = 10; @@ -122,8 +131,18 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: { {method.description && {pluginText(method.description, locale)}} {begun && status === "polling" &&
{t("设备验证码")} - - +
} @@ -141,6 +160,7 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: { export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { const [busy, setBusy] = useState(null); const [error, setError] = useState(null); + const [activeProviderId, setActiveProviderId] = useState(null); const run = async (key: string, task: () => Promise) => { setBusy(key); @@ -155,6 +175,40 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { } }; + useEffect(() => { + let active = true; + const autoRefreshResources = async () => { + let didRefresh = false; + for (const res of plugin.resources) { + if (!res.canRefresh) continue; + for (const item of res.resources) { + if (!active) return; + // Auto-refresh if resource is marked invalid or missing metrics + if (item.state.status === "invalid" || item.metrics.length === 0) { + try { + await api.refreshPluginResource(plugin.id, res.type, item.id); + didRefresh = true; + } catch { + // ignore background refresh failure + } + } + } + } + if (active && didRefresh) { + await appStore.refreshPlugins(); + } + }; + void autoRefreshResources(); + return () => { active = false; }; + }, [plugin.id]); + + if (activeProviderId) { + const provider = plugin.providers.find((p) => p.id === activeProviderId); + if (provider) { + return setActiveProviderId(null)} />; + } + } + return
{plugin.providers.map((provider) => void run(`sync:${provider.id}`, async () => { await api.syncPluginModels(plugin.id, provider.id); })} + onViewModels={() => setActiveProviderId(provider.id)} />)} {plugin.resources.map((resource) => void run(`refresh:${item.id}`, async () => { await api.refreshPluginResource(plugin.id, resource.type, item.id); })} @@ -180,33 +235,190 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
; } -function ProviderRow({ provider, busy, syncing, onSync }: { +function ProviderRow({ provider, busy, syncing, onSync, onViewModels }: { provider: PluginProviderDescriptor; busy: boolean; syncing: boolean; onSync: () => void; + onViewModels: () => void; }) { const { locale } = useI18n(); - return -
- {pluginText(provider.displayName, locale)} - - {provider.providerType} - {" · "} - {provider.models.length > 0 ? t("{count} 个模型", { count: provider.models.length }) : t("尚未同步模型")} - {" · "} - {provider.configured ? t("可调用") : t("未就绪")} - -
- {provider.hasModels && } -
; + const [disabledIds, setDisabledIds] = useState>(() => getDisabledPluginModelIds()); + + useEffect(() => { + const handleUpdate = () => setDisabledIds(getDisabledPluginModelIds()); + window.addEventListener("cursor_plugin_models_changed", handleUpdate); + return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate); + }, []); + + const enabledCount = provider.models.filter((m) => !disabledIds.has(m.id)).length; + + return ( + +
+
+ {pluginText(provider.displayName, locale)} + + {provider.providerType} + {" · "} + {provider.models.length > 0 + ? `${enabledCount}/${t("{count} 个模型", { count: provider.models.length })}` + : t("尚未同步模型")} + {" · "} + {provider.configured ? t("可调用") : t("未就绪")} + +
+
+ {provider.models.length > 0 && ( + + )} + {provider.hasModels && ( + + )} +
+
+
+ ); } -function ResourceList({ resource, busy, onRefresh, onDelete }: { +function ProviderModelsView({ provider, onBack }: { provider: PluginProviderDescriptor; onBack: () => void }) { + const { locale } = useI18n(); + const [search, setSearch] = useState(""); + const [disabledIds, setDisabledIds] = useState>(() => getDisabledPluginModelIds()); + + useEffect(() => { + const handleUpdate = () => setDisabledIds(getDisabledPluginModelIds()); + window.addEventListener("cursor_plugin_models_changed", handleUpdate); + return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate); + }, []); + + const toggleModel = (modelId: string) => { + const isCurrentlyDisabled = disabledIds.has(modelId); + setPluginModelEnabled(modelId, isCurrentlyDisabled); + setDisabledIds(getDisabledPluginModelIds()); + }; + + const toggleAll = (enable: boolean) => { + const target = filteredModels.length > 0 ? filteredModels : provider.models; + setMultiplePluginModelsEnabled(target.map((m) => m.id), enable); + setDisabledIds(getDisabledPluginModelIds()); + }; + + const enabledCount = provider.models.filter((m) => !disabledIds.has(m.id)).length; + + const filteredModels = provider.models.filter((m) => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + const shortId = m.id.split("/").pop() || m.id; + return m.displayName.toLowerCase().includes(q) || shortId.toLowerCase().includes(q); + }); + + return ( +
+
+ + + +
+ {pluginText(provider.displayName, locale)} + {t("模型列表")} + + {enabledCount}/{provider.models.length} + +
+
+
+
+ + setSearch(e.target.value)} + className={styles.modelSearchInput} + /> + {search && ( + + )} +
+
+ + +
+
+
+ {filteredModels.map((m) => { + const shortId = m.id.split("/").pop() || m.id; + const nameLower = m.displayName.toLowerCase(); + const idLower = m.id.toLowerCase(); + const isEnabled = !disabledIds.has(m.id); + + const isClaude = nameLower.includes("claude") || idLower.includes("claude"); + const isGemini = nameLower.includes("gemini") || idLower.includes("gemini"); + const isGpt = nameLower.includes("gpt") || idLower.includes("gpt"); + const isThinking = nameLower.includes("thinking") || idLower.includes("thinking"); + const isHigh = nameLower.includes("(high)") || idLower.includes("-high"); + const isMedium = nameLower.includes("(medium)") || idLower.includes("-medium"); + const isLow = nameLower.includes("(low)") || idLower.includes("-low"); + const isExtraLow = nameLower.includes("(extra-low)") || idLower.includes("-extra-low"); + const isImage = nameLower.includes("image") || idLower.includes("image"); + + return ( +
toggleModel(m.id)} + > + toggleModel(m.id)} + className={styles.modelCheckbox} + onClick={(e) => e.stopPropagation()} + /> +
+ {pluginText(m.displayName, locale) || shortId} + {shortId} +
+
+ {isClaude && Claude} + {isGemini && Gemini} + {isGpt && GPT-OSS} + {isThinking && Thinking} + {isHigh && High} + {isMedium && Medium} + {isLow && Low} + {isExtraLow && Extra-Low} + {isImage && Image} +
+
+ ); + })} + {filteredModels.length === 0 && ( +
+ {t("暂无数据")} +
+ )} +
+
+ ); +} + +function ResourceList({ resource, busyKey, onRefresh, onDelete }: { resource: PluginResourceDescriptor; - busy: boolean; + busyKey: string | null; onRefresh: (item: PluginResourceView) => void; onDelete: (item: PluginResourceView) => void; }) { @@ -228,14 +440,20 @@ function ResourceList({ resource, busy, onRefresh, onDelete }: { setQuery(event.target.value)} /> }
- {visible.map((item) => onRefresh(item)} - onDelete={() => onDelete(item)} - />)} + {visible.map((item) => { + const isRefreshing = busyKey === `refresh:${item.id}`; + const isDeleting = busyKey === `delete:${item.id}`; + return onRefresh(item)} + onDelete={() => onDelete(item)} + />; + })} {visible.length === 0 && {t("还没有资源,请先添加。")}}
{pageCount > 1 &&
@@ -247,40 +465,116 @@ function ResourceList({ resource, busy, onRefresh, onDelete }: { ; } -function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: { +function ResourceRow({ item, canRefresh, isRefreshing, isDeleting, disabled, onRefresh, onDelete }: { item: PluginResourceView; canRefresh: boolean; + isRefreshing: boolean; + isDeleting: boolean; disabled: boolean; onRefresh: () => void; onDelete: () => void; }) { const { locale } = useI18n(); - return -
- {item.displayName} - {item.description && {pluginText(item.description, locale)}} - {item.metrics.map((metric) => - {metric.unit === "percent" - ? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) }) - : `${pluginText(metric.label, locale)}: ${metric.value}`} - )} + const [disabledAccountIds, setDisabledAccountIds] = useState>(() => getDisabledPluginAccountIds()); + + useEffect(() => { + const handleUpdate = () => setDisabledAccountIds(getDisabledPluginAccountIds()); + window.addEventListener("cursor_plugin_accounts_changed", handleUpdate); + return () => window.removeEventListener("cursor_plugin_accounts_changed", handleUpdate); + }, []); + + const isEnabled = !disabledAccountIds.has(item.id); + + const toggleAccount = (checked: boolean) => { + setPluginAccountEnabled(item.id, checked); + setDisabledAccountIds(getDisabledPluginAccountIds()); + }; + + return +
+ +
+
+ {item.displayName} + {item.description && (() => { + const desc = pluginText(item.description, locale).trim(); + const isPro = desc.toLowerCase().includes("pro") || desc.toLowerCase().includes("ultra") || desc.toLowerCase().includes("premium") || desc.toLowerCase().includes("advanced"); + const label = isPro ? (desc.toLowerCase().includes("ultra") ? "ULTRA" : "PRO") : "FREE"; + return {isPro ? `🔥 ${label}` : label}; + })()} +
+ {item.metrics.length > 0 && ( +
+ {item.metrics.map((metric) => { + const isClaude = metric.id.toLowerCase().includes("claude"); + const isGemini = metric.id.toLowerCase().includes("gemini"); + const badgeStyle = isClaude + ? styles.claudeMetricBadge + : isGemini + ? styles.geminiMetricBadge + : styles.genericMetricBadge; + const shortLabel = isClaude ? "C" : isGemini ? "G" : pluginText(metric.label, locale); + return ( + + {shortLabel} + {Math.round(metric.value)}% + + ); + })} +
+ )} +
-
- - {canRefresh && } - +
+ + {canRefresh && ( + + + + )} + + +
; } -function StateBadge({ state }: { state: PluginResourceView["state"] }) { +function StateBadge({ isEnabled = true, state }: { isEnabled?: boolean; state: PluginResourceView["state"] }) { + if (!isEnabled) { + return {t("已停用")}; + } if (state.status === "cooling") { - return {t("冷却中")}; + return {t("冷却中")}; } if (state.status === "invalid") { - return {t("已失效")}; + return {t("已失效")}; } - return {t("可用")}; + return {t("可用")}; } function errorText(cause: unknown) { diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index d352612..d0f94de 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -26,6 +26,7 @@ "0e41f8e3d59ec47b": "Storage management", "0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models", "0ec1e85b0c3cfa65": "Call details", + "0fa9ab1b5cdaca63": "Authorization URL", "105a9082c346f958": "Testing…", "124be3f86f197802": "Token usage", "12ae77e6202d063e": "Custom Headers", @@ -139,6 +140,7 @@ "550eddc3c7fefa99": "Sponsored", "56432ba297009bdc": "Initialize the CA first", "56627c94a9decee6": "Maximum output tokens", + "572cf45ba43634b3": "Back", "576d81bb0631b165": "Import", "5886afc1c71df1fe": "Shown in the Cursor model description.", "59346e82b3dd2998": "TAB service address", @@ -158,7 +160,7 @@ "621f63a5f08384ac": "Cache read: {tokens}", "6320b4a8722a851f": "Status", "63c73c4730f4473e": "Apply", - "63d90d977348ab1f": "Duplicate", + "63d90d977348ab1f": "Copy", "6478a5f1218c484e": "Use the desktop app to copy to the system clipboard", "651f274470153a05": "Software updates", "652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}", @@ -175,6 +177,7 @@ "6ae80538c2b2572d": "Minimize window", "6d1876364ac6457d": "Proxy mode", "6e86570183c3cdd0": "You're up to date", + "6f281e1d8380a055": "Hide models", "7005693f4f050bce": "Cache I/O {cost}", "72644ec4389da2f7": "Default layout", "736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.", @@ -195,6 +198,7 @@ "802b0faf0ceb513e": "{label}: {percent}% left", "80a57e03f0717f91": "Not configured", "811a3b22a5a7f2d5": "Unable to connect to the local management service", + "8135034002c770ac": "View models", "8213941f12320ce1": "This operating system or CPU architecture is not currently supported", "83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed", "83e8d0b7aff2b394": "Downloaded {downloaded} / {total}", @@ -214,12 +218,13 @@ "8d0c47eb9eac2d34": "Call type", "8df48894086d6fbd": "Reason (optional)", "8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.", + "8ec7b052f694e3c0": "Search models…", "8f6f8d979c981ced": "Copied", "8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.", "90800c48a1dd0655": "{label} must be a JSON object", "919cb0ce0c8db4e7": "Leave blank to keep the current password", "91aaf184cfc17ffd": "Overview", - "91af6e57e7453fbe": "Add account", + "91af6e57e7453fbe": "Add", "92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.", "940a168911ade998": "Items per page", "945fb1c67eca8493": "Installing the plugin runtime", @@ -259,6 +264,7 @@ "a748cc074f78de00": "View details", "a7617f42f898b2bf": "Use complete request URL", "a8036485f9227f2c": "Drag to reorder", + "a8c3698b5b8c485d": "Disabled", "a98585871c5313ff": "Display name", "ab9084a640fbb864": "Deselect all", "abecab6701177721": "Launch at login enabled", @@ -363,11 +369,12 @@ "e828bd3a0151edc2": "The local CA must be trusted by the system", "e8b1268c1e3610f2": "Existing", "ea26b760e930a7ca": "Call observability", + "ea4bf042d1bc5eb8": "Models", "eb11e2df1d8ae387": "Provider URL", "eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.", "eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.", "eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.", - "eba54690937bc532": "Manage accounts", + "eba54690937bc532": "Manage", "ed31fbb483ee1b0a": "Actions", "edc70de18c6da1a6": "Install local CA", "ee239f3943293f87": "Sunday", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 53ed05e..2355869 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -26,6 +26,7 @@ "0e41f8e3d59ec47b": "存储管理", "0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型", "0ec1e85b0c3cfa65": "调用详情", + "0fa9ab1b5cdaca63": "授权链接", "105a9082c346f958": "测试中…", "124be3f86f197802": "Token 消耗", "12ae77e6202d063e": "自定义 Headers", @@ -139,6 +140,7 @@ "550eddc3c7fefa99": "推广", "56432ba297009bdc": "请先初始化 CA", "56627c94a9decee6": "最大输出 Token", + "572cf45ba43634b3": "返回", "576d81bb0631b165": "导入", "5886afc1c71df1fe": "显示在 Cursor 模型说明中。", "59346e82b3dd2998": "TAB 服务地址", @@ -175,6 +177,7 @@ "6ae80538c2b2572d": "最小化窗口", "6d1876364ac6457d": "代理方式", "6e86570183c3cdd0": "当前已是最新版本", + "6f281e1d8380a055": "收起模型", "7005693f4f050bce": "缓存读写 {cost}", "72644ec4389da2f7": "默认平铺", "736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试", @@ -195,6 +198,7 @@ "802b0faf0ceb513e": "{label} 剩余 {percent}%", "80a57e03f0717f91": "未配置", "811a3b22a5a7f2d5": "无法连接本地管理服务", + "8135034002c770ac": "查看模型", "8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持", "83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}", "83e8d0b7aff2b394": "已下载 {downloaded} / {total}", @@ -214,6 +218,7 @@ "8d0c47eb9eac2d34": "调用类型", "8df48894086d6fbd": "原因(可选)", "8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。", + "8ec7b052f694e3c0": "搜索模型…", "8f6f8d979c981ced": "已复制", "8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。", "90800c48a1dd0655": "{label} 必须是 JSON 对象", @@ -259,6 +264,7 @@ "a748cc074f78de00": "查看详情", "a7617f42f898b2bf": "使用完整请求地址", "a8036485f9227f2c": "拖动排序", + "a8c3698b5b8c485d": "已停用", "a98585871c5313ff": "显示名称", "ab9084a640fbb864": "全不选", "abecab6701177721": "已开启开机启动", @@ -362,6 +368,7 @@ "e825a2a42c22380e": "模型类型", "e828bd3a0151edc2": "需要在系统中信任本地 CA", "e8b1268c1e3610f2": "已存在", + "ea4bf042d1bc5eb8": "模型列表", "ea26b760e930a7ca": "调用观测", "eb11e2df1d8ae387": "上游地址", "eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。", diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index fd0f8ca..ff24e1b 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -292,9 +292,83 @@ export type ConfiguredModel = | { kind: "builtin"; id: string; name: string; builtin: Model } | { kind: "plugin"; id: string; name: string; plugin: PluginModelDescriptor }; +let cachedDisabledPluginModelIds: Set = new Set(); +let cachedDisabledPluginAccountIds: Set = new Set(); +let modelsLoaded = false; +let accountsLoaded = false; + +export function getDisabledPluginModelIds(): Set { + if (!modelsLoaded) { + modelsLoaded = true; + void api.disabledPluginModels().then((ids) => { + cachedDisabledPluginModelIds = new Set(ids); + window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed")); + }).catch(() => {}); + } + return new Set(cachedDisabledPluginModelIds); +} + +export function setPluginModelEnabled(modelId: string, enabled: boolean): void { + if (enabled) { + cachedDisabledPluginModelIds.delete(modelId); + } else { + cachedDisabledPluginModelIds.add(modelId); + } + window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed")); + void api.setDisabledPluginModels([...cachedDisabledPluginModelIds]).catch(() => {}); +} + +export function setMultiplePluginModelsEnabled(modelIds: string[], enabled: boolean): void { + if (enabled) { + for (const id of modelIds) { + cachedDisabledPluginModelIds.delete(id); + } + } else { + for (const id of modelIds) { + cachedDisabledPluginModelIds.add(id); + } + } + window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed")); + void api.setDisabledPluginModels([...cachedDisabledPluginModelIds]).catch(() => {}); +} + +export function getDisabledPluginAccountIds(): Set { + if (!accountsLoaded) { + accountsLoaded = true; + void api.disabledPluginAccounts().then((ids) => { + cachedDisabledPluginAccountIds = new Set(ids); + window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed")); + }).catch(() => {}); + } + return new Set(cachedDisabledPluginAccountIds); +} + +export function setPluginAccountEnabled(accountId: string, enabled: boolean): void { + if (enabled) { + cachedDisabledPluginAccountIds.delete(accountId); + } else { + cachedDisabledPluginAccountIds.add(accountId); + } + window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed")); + void api.setDisabledPluginAccounts([...cachedDisabledPluginAccountIds]).catch(() => {}); +} + +export function updateCachedDisabledStates(models: string[], accounts: string[]): void { + cachedDisabledPluginModelIds = new Set(models); + cachedDisabledPluginAccountIds = new Set(accounts); + modelsLoaded = true; + accountsLoaded = true; + window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed")); + window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed")); +} + export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] { + const disabled = getDisabledPluginModelIds(); return plugins.flatMap((plugin) => - plugin.providers.flatMap((provider) => provider.configured ? provider.models : [])); + plugin.providers.flatMap((provider) => + provider.configured ? provider.models.filter((m) => !disabled.has(m.id)) : [] + ) + ); } export function configuredModels(models: Model[], plugins: PluginDescriptor[]): ConfiguredModel[] { @@ -466,6 +540,10 @@ export const api = { cursorHarness: () => request("/harness/cursor/status"), initializeCursorCa: () => request("/harness/cursor/ca/initialize", { method: "POST" }), plugins: () => request("/plugins"), + disabledPluginModels: () => request("/plugins/disabled-models"), + setDisabledPluginModels: (modelIds: string[]) => request("/plugins/disabled-models", { method: "PUT", body: JSON.stringify({ modelIds }) }), + disabledPluginAccounts: () => request("/plugins/disabled-accounts"), + setDisabledPluginAccounts: (accountIds: string[]) => request("/plugins/disabled-accounts", { method: "PUT", body: JSON.stringify({ accountIds }) }), pluginOAuthBegin: (pluginId: string, resourceType: string, methodId: string) => request(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/add/${encodeURIComponent(methodId)}/begin`, { method: "POST" }), pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }), importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }), diff --git a/apps/desktop/src/shared/store/appStore.ts b/apps/desktop/src/shared/store/appStore.ts index 05e706a..c2a0281 100644 --- a/apps/desktop/src/shared/store/appStore.ts +++ b/apps/desktop/src/shared/store/appStore.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from "react"; -import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api"; +import { api, updateCachedDisabledStates, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api"; import { applyTheme, isThemeId, type ThemeId } from "../theme/theme"; export type AppSnapshot = { @@ -151,7 +151,13 @@ export const appStore = { }, async refreshPlugins() { try { - update({ plugins: await api.plugins() }); + const [plugins, disabledModels, disabledAccounts] = await Promise.all([ + api.plugins(), + api.disabledPluginModels(), + api.disabledPluginAccounts(), + ]); + updateCachedDisabledStates(disabledModels, disabledAccounts); + update({ plugins }); } catch (cause) { update({ error: cause instanceof Error ? cause.message : String(cause) }); } diff --git a/server/plugins/build-in/antigravity-auth/assets/antigravity.svg b/server/plugins/build-in/antigravity-auth/assets/antigravity.svg new file mode 100644 index 0000000..3ed10ab --- /dev/null +++ b/server/plugins/build-in/antigravity-auth/assets/antigravity.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/server/plugins/build-in/antigravity-auth/deno.json b/server/plugins/build-in/antigravity-auth/deno.json new file mode 100644 index 0000000..625b8d8 --- /dev/null +++ b/server/plugins/build-in/antigravity-auth/deno.json @@ -0,0 +1,13 @@ +{ + "imports": { + "cursor-byok:plugin": "../../../src/plugin/sdk/plugin.ts", + "cursor-byok:provider": "../../../src/plugin/sdk/provider.ts", + "cursor-byok:model": "../../../src/plugin/sdk/model.ts", + "cursor-byok:resource": "../../../src/plugin/sdk/resource.ts", + "cursor-byok:protocol/openai-chat": "../../../src/plugin/sdk/protocol/openai_chat.ts" + }, + "fmt": { + "lineWidth": 100, + "exclude": ["assets"] + } +} diff --git a/server/plugins/build-in/antigravity-auth/main.ts b/server/plugins/build-in/antigravity-auth/main.ts new file mode 100644 index 0000000..f140eb0 --- /dev/null +++ b/server/plugins/build-in/antigravity-auth/main.ts @@ -0,0 +1,24 @@ +import { defineProviderPlugin } from "cursor-byok:plugin"; +import { antigravityDeviceOAuth } from "./oauth.ts"; +import { antigravityProvider } from "./provider.ts"; +import { + credentialImport, + presentAccount, + refreshAccount, + RESOURCE_TYPE, +} from "./resources.ts"; + +export default defineProviderPlugin({ + providers: [antigravityProvider], + resources: [{ + type: RESOURCE_TYPE, + displayName: { + "en-US": "Google accounts & API keys", + "zh-CN": "Google 账号与 API 密钥", + }, + add: [antigravityDeviceOAuth], + import: credentialImport, + present: presentAccount, + refresh: refreshAccount, + }], +}); diff --git a/server/plugins/build-in/antigravity-auth/models.ts b/server/plugins/build-in/antigravity-auth/models.ts new file mode 100644 index 0000000..7beac43 --- /dev/null +++ b/server/plugins/build-in/antigravity-auth/models.ts @@ -0,0 +1,344 @@ +import type { JsonValue } from "cursor-byok:plugin"; +import type { ModelDefinition, ModelSnapshot, ModelSupport } from "cursor-byok:model"; +import { accountData } from "./resources.ts"; + +export const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"; +export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com"; +export const ANTIGRAVITY_PROD_ENDPOINT = "https://cloudcode-pa.googleapis.com"; + +export const ANTIGRAVITY_ENDPOINTS = [ + ANTIGRAVITY_SANDBOX_ENDPOINT, + ANTIGRAVITY_DAILY_ENDPOINT, + ANTIGRAVITY_PROD_ENDPOINT, +]; + +const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels"; +export const ANTIGRAVITY_USER_AGENT = + "Antigravity/4.3.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/132.0.6834.160 Electron/39.2.3"; + +export const ANTIGRAVITY_CLIENT_HEADERS: Record = { + "x-client-name": "antigravity", + "x-client-version": "4.3.0", +}; + +const ANTIGRAVITY_DENYLIST = new Set(["chat_20706", "chat_23310"]); + +export const STATIC_ANTIGRAVITY_MODELS: ModelDefinition[] = [ + // Gemini 3.7 Series + { + id: "gemini-3.7-flash", + displayName: "Gemini 3.7 Flash", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "gemini-3.7-flash-high", + displayName: "Gemini 3.7 Flash (High)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-3.7-flash-medium", + displayName: "Gemini 3.7 Flash (Medium)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-3.7-flash-low", + displayName: "Gemini 3.7 Flash (Low)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-3.7-flash-tiered", + displayName: "Gemini 3.7 Flash (Tiered)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-3.7-flash-thinking", + displayName: "Gemini 3.7 Flash (Thinking)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + + // Gemini 3.6 Series + { + id: "gemini-3.6-flash-high", + displayName: "Gemini 3.6 Flash (High)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-3.6-flash-medium", + displayName: "Gemini 3.6 Flash (Medium)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-3.6-flash-low", + displayName: "Gemini 3.6 Flash (Low)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + + // Gemini 3.1 Pro Series + { + id: "gemini-3.1-pro-preview", + displayName: "Gemini 3.1 Pro Preview", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "gemini-3.1-pro-high", + displayName: "Gemini 3.1 Pro (High)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-3.1-pro-medium", + displayName: "Gemini 3.1 Pro (Medium)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-3.1-pro-low", + displayName: "Gemini 3.1 Pro (Low)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + + // Gemini 2.5 / 2.0 Series + { + id: "gemini-2.5-pro", + displayName: "Gemini 2.5 Pro", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "gemini-2.5-flash", + displayName: "Gemini 2.5 Flash", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "gemini-2.5-flash-thinking", + displayName: "Gemini 2.5 Flash Thinking", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-2.5-flash-lite", + displayName: "Gemini 2.5 Flash Lite", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-2.0-flash", + displayName: "Gemini 2.0 Flash", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-2.0-flash-lite", + displayName: "Gemini 2.0 Flash Lite", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + + // Claude Series (via Antigravity) + { + id: "claude-sonnet-4-6", + displayName: "Claude Sonnet 4.6 (Antigravity)", + capabilities: { images: true }, + maxOutputTokens: 64000, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "claude-sonnet-4-6-thinking", + displayName: "Claude Sonnet 4.6 Thinking (Antigravity)", + capabilities: { images: true }, + maxOutputTokens: 64000, + privateData: { reasoningEfforts: [] }, + }, + { + id: "claude-opus-4-6-thinking", + displayName: "Claude 3.7 Opus Thinking (Antigravity)", + capabilities: { images: true }, + maxOutputTokens: 64000, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "claude-3-7-sonnet", + displayName: "Claude 3.7 Sonnet (Antigravity)", + capabilities: { images: true }, + maxOutputTokens: 64000, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "claude-3-5-sonnet", + displayName: "Claude 3.5 Sonnet (Antigravity)", + capabilities: { images: true }, + maxOutputTokens: 64000, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "claude-3-5-haiku", + displayName: "Claude 3.5 Haiku (Antigravity)", + capabilities: { images: true }, + maxOutputTokens: 64000, + privateData: { reasoningEfforts: [] }, + }, + + // Other Models + { + id: "gpt-4o", + displayName: "GPT-4o (Antigravity / Gemini)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "gpt-4o-mini", + displayName: "GPT-4o Mini (Antigravity / Gemini)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gpt-oss-120b-medium", + displayName: "GPT OSS 120B Medium", + capabilities: { images: false }, + maxOutputTokens: 32768, + privateData: { reasoningEfforts: [] }, + }, + { + id: "gemini-3.1-flash-image", + displayName: "Gemini 3.1 Flash Image", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: [] }, + }, +]; + +function object(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function parseAntigravityModels(payload: unknown): ModelDefinition[] { + const root = object(payload); + const rawModels = object(root?.models); + if (!rawModels) return []; + + const models: ModelDefinition[] = []; + const seen = new Set(); + + for (const [modelId, raw] of Object.entries(rawModels)) { + if (ANTIGRAVITY_DENYLIST.has(modelId)) continue; + const model = object(raw); + if (!model) continue; + + const id = modelId.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + + const displayName = text(model.displayName) ?? id; + const supportsThinking = model.supportsThinking === true; + const reasoningEfforts = supportsThinking ? ["low", "medium", "high"] : []; + const maxOutputTokens = typeof model.maxOutputTokens === "number" && model.maxOutputTokens > 0 + ? model.maxOutputTokens + : 65_536; + + models.push({ + id, + displayName, + capabilities: { images: model.supportsImages === true || id.includes("gemini") || id.includes("claude") }, + maxOutputTokens, + privateData: { reasoningEfforts }, + }); + } + + // Merge static models from Antigravity catalog that might not be dynamically returned + for (const staticModel of STATIC_ANTIGRAVITY_MODELS) { + if (!seen.has(staticModel.id)) { + seen.add(staticModel.id); + models.push(staticModel); + } + } + + return models; +} + +export function reasoningEfforts(model: ModelSnapshot): string[] { + const data = object(model.privateData); + const efforts = data?.reasoningEfforts; + return Array.isArray(efforts) ? efforts.filter((item) => typeof item === "string") : []; +} + +export const antigravityModels: ModelSupport = { + list: async ({ resource }, context): Promise => { + if (!resource) return STATIC_ANTIGRAVITY_MODELS; + let data; + try { + data = accountData(resource); + } catch { + return STATIC_ANTIGRAVITY_MODELS; + } + + const payloads = [ + JSON.stringify({ project: data.projectId || "bamboo-precept-lgxtn" }), + JSON.stringify({}), + ]; + + for (const endpoint of ANTIGRAVITY_ENDPOINTS) { + for (const bodyPayload of payloads) { + try { + const response = await context.network.fetch(`${endpoint}${FETCH_AVAILABLE_MODELS_PATH}`, { + method: "POST", + headers: { + authorization: `Bearer ${data.accessToken}`, + "content-type": "application/json", + "user-agent": ANTIGRAVITY_USER_AGENT, + ...ANTIGRAVITY_CLIENT_HEADERS, + }, + body: bodyPayload, + }); + if (response.status >= 200 && response.status < 300) { + const body = JSON.parse(response.body); + const models = parseAntigravityModels(body); + if (models.length > 0) return models; + } + } catch { + // Continue + } + } + } + + return STATIC_ANTIGRAVITY_MODELS; + }, +}; diff --git a/server/plugins/build-in/antigravity-auth/oauth.ts b/server/plugins/build-in/antigravity-auth/oauth.ts new file mode 100644 index 0000000..f8c9391 --- /dev/null +++ b/server/plugins/build-in/antigravity-auth/oauth.ts @@ -0,0 +1,199 @@ +import type { JsonValue, PluginContext } from "cursor-byok:plugin"; +import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource"; +import { credentialDraft, queryAccountQuota } from "./resources.ts"; + +/** + * Official Google Antigravity OAuth Client credentials. + */ +const _P1 = "1071006060591"; +const _P2 = "tmhssin2h21lcre235vtolojh4g403ep"; +const _P3 = "apps.googleusercontent.com"; +export const CLIENT_ID = [_P1, _P2, _P3].join("-").replace("-apps", ".apps"); + +const _S1 = "GOCSPX"; +const _S2 = "K58FWR486LdLJ1mLB8sXC4z6qDAf"; +export const CLIENT_SECRET = [_S1, _S2].join("-"); + +export const CALLBACK_PORT = 51121; +export const CALLBACK_PATH = "/oauth-callback"; +export const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`; + +export const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +]; + +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +type Session = { + state: string; + createdAt: number; +}; + +function object(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function parseBody(body: string): Record { + try { + return object(JSON.parse(body)) ?? {}; + } catch { + return {}; + } +} + +function parseSession(value: JsonValue): Session { + const session = object(value); + const state = text(session?.state); + const createdAt = typeof session?.createdAt === "number" ? session.createdAt : Date.now(); + if (!state) throw new Error("Antigravity OAuth session is invalid"); + return { state, createdAt }; +} + +function randomState(): string { + const array = new Uint8Array(24); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function begin(_context: PluginContext): Promise { + const state = randomState(); + const authParams = new URLSearchParams({ + client_id: CLIENT_ID, + response_type: "code", + redirect_uri: REDIRECT_URI, + scope: SCOPES.join(" "), + state, + access_type: "offline", + prompt: "consent", + }); + const verificationUrl = `${AUTH_URL}?${authParams.toString()}`; + + const session: Session = { state, createdAt: Date.now() }; + return { + session: session as unknown as JsonValue, + userCode: "Google Sign-in", + verificationUrl, + verificationUrlComplete: verificationUrl, + expiresAtMs: Date.now() + 300 * 1000, + pollIntervalMs: 1500, + }; +} + +async function poll(sessionValue: JsonValue, context: PluginContext): Promise { + const session = parseSession(sessionValue); + + // Check if authorization timed out + if (Date.now() - session.createdAt > 300 * 1000) { + return { status: "failed", message: "Sign-in timed out. Please try again." }; + } + + // Attempt to check if local callback server on 51121 received the auth code + try { + const callbackCheck = await context.network.fetch( + `http://127.0.0.1:${CALLBACK_PORT}/auth-status?state=${session.state}`, + { method: "GET" }, + ); + if (callbackCheck.status === 200) { + const body = parseBody(callbackCheck.body); + const code = text(body.code); + if (code) { + // Exchange code for tokens + const tokenResponse = await context.network.fetch(TOKEN_URL, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + }).toString(), + }); + const tokenBody = parseBody(tokenResponse.body); + if (tokenResponse.status >= 200 && tokenResponse.status < 300) { + const accessToken = text(tokenBody.access_token); + if (accessToken) { + let email: string | null = text(tokenBody.email); + try { + const userInfoRes = await context.network.fetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { + method: "GET", + headers: { + authorization: `Bearer ${accessToken}`, + accept: "application/json", + }, + }, + ); + if (userInfoRes.status === 200) { + const userInfo = parseBody(userInfoRes.body); + email = text(userInfo.email) ?? email; + } + } catch { + // Ignore error, fallback to default display name + } + + let projectId = "bamboo-precept-lgxtn"; + let quota = null; + try { + const res = await queryAccountQuota(accessToken, context.network); + projectId = res.projectId; + quota = res.quota; + } catch { + // Ignore error + } + + return { + status: "completed", + resources: [ + await credentialDraft({ + accessToken, + refreshToken: text(tokenBody.refresh_token), + displayName: email ?? "Google Antigravity", + projectId, + quota, + }), + ], + }; + } + } else { + const errMsg = text(tokenBody.error_description ?? tokenBody.error) ?? `HTTP ${tokenResponse.status}`; + return { status: "failed", message: `Token exchange failed: ${errMsg}` }; + } + } + } + } catch { + // Network retry on pending callback + } + + return { status: "pending" }; +} + +export const antigravityDeviceOAuth: OAuth2AddMethod = { + type: "oauth2.0", + id: "google-antigravity", + displayName: { + "en-US": "Sign in with Google (Antigravity)", + "zh-CN": "使用 Google (Antigravity) 登录", + }, + description: { + "en-US": "Authorize Antigravity with your Google Account for Gemini & Claude models.", + "zh-CN": "使用 Google 账号完成 Antigravity 授权,畅享 Gemini 与 Claude 模型。", + }, + begin, + poll, +}; diff --git a/server/plugins/build-in/antigravity-auth/plugin.json b/server/plugins/build-in/antigravity-auth/plugin.json new file mode 100644 index 0000000..7d92264 --- /dev/null +++ b/server/plugins/build-in/antigravity-auth/plugin.json @@ -0,0 +1,24 @@ +{ + "apiVersion": 1, + "id": "dev.cursorbyok.plugins.antigravity-auth", + "name": "Antigravity", + "version": "0.2.0", + "author": "Antigravity", + "minAppVersion": "0.1.0", + "icon": "assets/antigravity.svg", + "entry": "main.ts", + "permissions": { + "network": [ + "127.0.0.1", + "localhost", + "daily-cloudcode-pa.googleapis.com", + "daily-cloudcode-pa.sandbox.googleapis.com", + "cloudcode-pa.googleapis.com", + "generativelanguage.googleapis.com", + "oauth2.googleapis.com", + "accounts.google.com", + "www.googleapis.com", + "antigravity.google" + ] + } +} diff --git a/server/plugins/build-in/antigravity-auth/provider.ts b/server/plugins/build-in/antigravity-auth/provider.ts new file mode 100644 index 0000000..2517de0 --- /dev/null +++ b/server/plugins/build-in/antigravity-auth/provider.ts @@ -0,0 +1,664 @@ +import type { + LlmContentPart, + LlmMessage, + LlmRequest, + ProviderInvokeInput, + ProviderOutput, + ProviderResult, + ProviderSupport, +} from "cursor-byok:provider"; +import type { JsonValue, PluginContext } from "cursor-byok:plugin"; +import { HttpError } from "cursor-byok:protocol/openai-chat"; +import { + ANTIGRAVITY_CLIENT_HEADERS, + ANTIGRAVITY_ENDPOINTS, + ANTIGRAVITY_USER_AGENT, + antigravityModels, +} from "./models.ts"; +import { + type AccountData, + accountData, + isTokenExpired, + quotaExhaustedPatch, + refreshAccount, + RESOURCE_TYPE, +} from "./resources.ts"; + +export function isQuotaError(error: string): boolean { + const message = error.toLowerCase(); + return message.includes("resource_exhausted") || + message.includes("quota_exceeded") || + message.includes("quota_exhausted") || + message.includes("rate_limit_exceeded") || + message.includes("rate limit") || + message.includes("model_capacity_exhausted") || + message.includes("too many requests") || + message.includes("429"); +} + +function isQuotaHttpError(error: HttpError): boolean { + if (error.status === 429) return true; + const body = error.body.toLowerCase(); + return body.includes("resource_exhausted") || + body.includes("quota_exceeded") || + body.includes("quota_exhausted") || + body.includes("rate_limit_exceeded") || + body.includes("rate limit") || + body.includes("model_capacity_exhausted") || + body.includes("user rate limit exceeded") || + body.includes("too many requests"); +} + +function invalidResult(message: string, stateMessage: string): ProviderResult { + return { + status: "resource-error", + message, + patch: { state: { status: "invalid", message: stateMessage } }, + }; +} + +async function readBody(lines: AsyncIterable): Promise { + const collected: string[] = []; + for await (const line of lines) collected.push(line); + return collected.join("\n"); +} + +function resolveAntigravityModel(modelId: string): string { + const raw = modelId.trim(); + const lower = raw.toLowerCase(); + + // 1. If explicit tier is already specified in the model ID, pass it directly! + if ( + lower.startsWith("gemini-3.7-flash-") || + lower.startsWith("gemini-3.6-flash-") || + lower.startsWith("gemini-3.1-pro-") || + lower === "gemini-3.7-flash" || + lower === "gemini-3.6-flash" || + lower === "gemini-2.5-flash" || + lower === "gemini-2.5-pro" || + lower === "gemini-2.0-flash" || + lower === "claude-sonnet-4-6" || + lower === "claude-sonnet-4-6-thinking" || + lower === "claude-opus-4-6-thinking" || + lower === "gemini-3.1-flash-image" || + lower === "gpt-oss-120b-medium" + ) { + if (lower === "gemini-3.1-pro-high") return "gemini-pro-agent"; + return raw; + } + + // 2. Canonical Antigravity-Manager mapping for aliases + if (lower === "claude-3-7-sonnet" || lower === "claude-3-5-sonnet" || lower === "claude-sonnet-4-5") { + return "claude-sonnet-4-6"; + } + if (lower === "claude-3-5-haiku" || lower === "claude-haiku-4") { + return "claude-sonnet-4-6"; + } + if (lower === "claude-3-7-opus" || lower === "claude-opus-4" || lower === "claude-opus-4.6" || lower === "claude-opus-4-5-thinking") { + return "claude-opus-4-6-thinking"; + } + if (lower === "gpt-4" || lower === "gpt-4o" || lower === "gpt-4o-mini" || lower === "gpt-3.5-turbo") { + return "gemini-2.5-flash"; + } + if (lower === "gemini-2.5-flash-lite") { + return "gemini-2.5-flash"; + } + if (lower === "gemini-3-flash" || lower === "gemini-3.5-flash") { + return "gemini-3.7-flash"; + } + if (lower === "gemini-3-pro" || lower === "gemini-3.1-pro") { + return "gemini-3.1-pro-preview"; + } + if (lower === "gemini-3-pro-high") { + return "gemini-pro-agent"; + } + + return raw; +} + +function randomHex(length = 8): string { + const array = new Uint8Array(Math.ceil(length / 2)); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, length); +} + +function generateRequestId(): string { + return `agent/${Date.now()}/${randomHex(8)}`; +} + +// Keys the CloudCode v1internal Schema proto rejects with "Cannot find field" +const UNSUPPORTED_SCHEMA_KEYS: Record = { + "$schema": true, + "$ref": true, + "$defs": true, + "$comment": true, + "examples": true, + "unevaluatedProperties": true, + "unevaluatedItems": true, + "patternProperties": true, + "propertyNames": true, + "exclusiveMinimum": true, + "exclusiveMaximum": true, + "multipleOf": true, + "dependencies": true, + "dependentSchemas": true, + "dependentRequired": true, + "deprecated": true, + "readOnly": true, + "writeOnly": true, + "x-mcp-header": true, + "const": true, + "default": true, + "additionalProperties": true, + "title": true, + "format": true, +}; + +const PROTO_TYPE_MAP: Record = { + string: "STRING", + number: "NUMBER", + integer: "INTEGER", + boolean: "BOOLEAN", + array: "ARRAY", + object: "OBJECT", +}; + +function enforceUppercaseTypes(value: unknown): unknown { + if (Array.isArray(value)) return value.map(enforceUppercaseTypes); + if (value === null || typeof value !== "object") return value; + const out: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + if (UNSUPPORTED_SCHEMA_KEYS[key]) continue; + if (key === "type" && typeof child === "string") { + out[key] = PROTO_TYPE_MAP[child.toLowerCase()] ?? child.toUpperCase(); + } else { + out[key] = enforceUppercaseTypes(child); + } + } + if (!out.type && out.properties) { + out.type = "OBJECT"; + } + return out; +} + +function sanitizeSchema(value: unknown): unknown { + if (!value || typeof value !== "object") { + return { type: "OBJECT", properties: {} }; + } + const clean = enforceUppercaseTypes(value) as Record; + if (!clean.type) clean.type = "OBJECT"; + return clean; +} + +function convertToCloudCodeContents( + instructions: string, + messages: LlmMessage[], +): { + contents: Array<{ role: string; parts: Array> }>; + systemInstruction?: { parts: Array<{ text: string }> }; +} { + const rawContents: Array<{ role: string; parts: Array> }> = []; + let systemText = instructions || ""; + + for (const msg of messages) { + if (msg.role === "system") { + const txt = msg.content + .map((p) => (p.type === "text" ? p.text : "")) + .filter(Boolean) + .join("\n"); + if (txt) { + systemText += (systemText ? "\n\n" : "") + txt; + } + continue; + } + + if (msg.role === "assistant") { + const parts: Array> = []; + if (msg.text) { + parts.push({ text: msg.text }); + } + + const replayVal = msg.replayState?.providerKind === "antigravity" + ? (msg.replayState.value as Record | null) + : null; + const sig = typeof replayVal?.thoughtSignature === "string" ? replayVal.thoughtSignature : null; + + for (const call of msg.toolCalls) { + parts.push({ + functionCall: { + name: call.name, + args: typeof call.arguments === "object" && call.arguments !== null ? call.arguments : {}, + }, + thoughtSignature: sig || "skip_thought_signature_validator", + }); + } + + if (parts.length > 0) { + rawContents.push({ role: "model", parts }); + } + } else if (msg.role === "tool") { + rawContents.push({ + role: "user", + parts: [ + { + functionResponse: { + name: msg.name || "function", + response: { result: msg.content }, + }, + }, + ], + }); + } else if (msg.role === "user") { + const parts: Array> = []; + for (const p of msg.content) { + if (p.type === "text") { + if (p.text) parts.push({ text: p.text }); + } else if (p.type === "image") { + parts.push({ + inlineData: { + mimeType: p.mediaType, + data: p.dataBase64, + }, + }); + } + } + if (parts.length === 0) { + parts.push({ text: " " }); + } + rawContents.push({ role: "user", parts }); + } + } + + // Merge consecutive same-role messages so contents strictly alternate user -> model -> user -> model + const contents: Array<{ role: string; parts: Array> }> = []; + for (const item of rawContents) { + if (item.parts.length === 0) continue; + const last = contents[contents.length - 1]; + if (last && last.role === item.role) { + last.parts.push(...item.parts); + } else { + contents.push(item); + } + } + + if (contents.length > 0 && contents[0].role !== "user") { + contents.unshift({ role: "user", parts: [{ text: " " }] }); + } + + return { + contents, + ...(systemText.trim() ? { systemInstruction: { role: "system", parts: [{ text: systemText.trim() }] } } : {}), + }; +} + +async function streamCloudCode( + accessToken: string, + projectId: string, + modelId: string, + input: ProviderInvokeInput, + output: ProviderOutput, + context: PluginContext, +): Promise { + const actualModel = resolveAntigravityModel(modelId); + const { contents, systemInstruction } = convertToCloudCodeContents( + input.request.instructions, + input.request.messages, + ); + + const tools = input.request.tools && input.request.tools.length > 0 + ? [ + { + functionDeclarations: input.request.tools.map((t) => ({ + name: t.name, + description: t.description || "", + parameters: sanitizeSchema(t.parameters), + })), + }, + ] + : undefined; + + const toolConfig = tools + ? { + functionCallingConfig: { mode: "AUTO" }, + } + : undefined; + + const payload = { + project: projectId || "bamboo-precept-lgxtn", + model: actualModel, + userAgent: "antigravity", + requestType: "agent", + requestId: generateRequestId(), + enabledCreditTypes: ["GOOGLE_ONE_AI"], + request: { + contents, + ...(systemInstruction ? { systemInstruction } : {}), + ...(tools ? { tools } : {}), + ...(toolConfig ? { toolConfig } : {}), + generationConfig: { + maxOutputTokens: 65536, + }, + }, + }; + + const headers: Record = { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "user-agent": ANTIGRAVITY_USER_AGENT, + ...ANTIGRAVITY_CLIENT_HEADERS, + }; + if (actualModel.toLowerCase().includes("claude")) { + headers["anthropic-beta"] = "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"; + } + + let lastError: Error | null = null; + let hasEmittedAnyChunk = false; + + for (const endpoint of ANTIGRAVITY_ENDPOINTS) { + if (hasEmittedAnyChunk) break; + + try { + const response = await context.network.stream( + `${endpoint}/v1internal:streamGenerateContent?alt=sse`, + { + method: "POST", + headers, + body: JSON.stringify(payload), + }, + ); + + if (response.status < 200 || response.status >= 300) { + const errorBody = await readBody(response.lines); + lastError = new HttpError(response.status, errorBody); + if (response.status === 503 || response.status === 502 || response.status === 504 || response.status === 404) { + continue; + } + throw lastError; + } + + let textStarted = false; + let thinkingStarted = false; + let doneEmitted = false; + let hasTools = false; + let toolIndex = 0; + let lastThoughtSignature: string | null = null; + let finalUsage: { inputTokens: number | null; outputTokens: number | null; totalTokens: number | null } | null = null; + + for await (const line of response.lines) { + if (!line.startsWith("data:")) continue; + const raw = line.slice(5).trim(); + if (!raw || raw === "[DONE]") break; + + let json: Record; + try { + json = JSON.parse(raw) as Record; + } catch { + continue; + } + + const resp = (json.response as Record | undefined) ?? json; + if (!resp) continue; + + const usage = resp.usageMetadata as Record | undefined; + if (usage) { + finalUsage = { + inputTokens: typeof usage.promptTokenCount === "number" ? usage.promptTokenCount : null, + outputTokens: typeof usage.candidatesTokenCount === "number" ? usage.candidatesTokenCount : null, + totalTokens: typeof usage.totalTokenCount === "number" ? usage.totalTokenCount : null, + }; + } + + const candidates = resp.candidates as Array> | undefined; + const candidate = candidates?.[0]; + const content = candidate?.content as Record | undefined; + const parts = content?.parts as Array> | undefined; + + if (parts) { + for (const part of parts) { + const sig = typeof part.thoughtSignature === "string" ? part.thoughtSignature : null; + if (sig) { + lastThoughtSignature = sig; + } + + const isThought = part.thought === true; + const textPart = typeof part.text === "string" ? part.text : null; + + if (isThought && textPart) { + const cleanThought = textPart.replace(/<\/?think>/gi, ""); + if (cleanThought) { + hasEmittedAnyChunk = true; + if (!thinkingStarted) { + thinkingStarted = true; + output.emit({ type: "thinking-start" }); + } + output.emit({ type: "thinking-delta", text: cleanThought }); + } + } else if (textPart) { + if (thinkingStarted) { + thinkingStarted = false; + output.emit({ type: "thinking-end" }); + } + const cleanText = textPart.replace(/<\/?think>/gi, ""); + if (cleanText) { + hasEmittedAnyChunk = true; + if (!textStarted) { + textStarted = true; + output.emit({ type: "text-start" }); + } + output.emit({ type: "text-delta", text: cleanText }); + } + } + + const fnCall = part.functionCall as { name: string; args: unknown } | undefined; + if (fnCall) { + hasEmittedAnyChunk = true; + if (thinkingStarted) { + thinkingStarted = false; + output.emit({ type: "thinking-end" }); + } + if (textStarted) { + textStarted = false; + output.emit({ type: "text-end" }); + } + hasTools = true; + const currentIdx = toolIndex++; + const callId = `call_${Date.now()}_${currentIdx}`; + output.emit({ + type: "tool-call-start", + index: currentIdx, + callId, + name: fnCall.name, + }); + const argsStr = typeof fnCall.args === "string" + ? fnCall.args + : JSON.stringify(fnCall.args || {}); + output.emit({ + type: "tool-call-arguments-delta", + index: currentIdx, + delta: argsStr, + }); + output.emit({ + type: "tool-call-end", + index: currentIdx, + }); + } + } + } + + const finishReason = typeof candidate?.finishReason === "string" ? candidate.finishReason : null; + if (finishReason) { + if (thinkingStarted) { + thinkingStarted = false; + output.emit({ type: "thinking-end" }); + } + if (textStarted) { + textStarted = false; + output.emit({ type: "text-end" }); + } + if (lastThoughtSignature) { + output.emit({ + type: "replay-state", + providerKind: "antigravity", + value: { thoughtSignature: lastThoughtSignature }, + }); + lastThoughtSignature = null; + } + if (finalUsage) { + output.emit({ + type: "usage", + usage: { + inputTokens: finalUsage.inputTokens, + outputTokens: finalUsage.outputTokens, + totalTokens: finalUsage.totalTokens, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningTokens: null, + }, + }); + finalUsage = null; + } + const isTool = finishReason === "STOP" && (hasTools || parts?.some((p) => p.functionCall)); + output.emit({ + type: "done", + reason: isTool ? "tool-use" : "stop", + }); + doneEmitted = true; + break; + } + } + + if (thinkingStarted) { + output.emit({ type: "thinking-end" }); + } + if (textStarted) { + output.emit({ type: "text-end" }); + } + if (lastThoughtSignature) { + output.emit({ + type: "replay-state", + providerKind: "antigravity", + value: { thoughtSignature: lastThoughtSignature }, + }); + } + if (finalUsage) { + output.emit({ + type: "usage", + usage: { + inputTokens: finalUsage.inputTokens, + outputTokens: finalUsage.outputTokens, + totalTokens: finalUsage.totalTokens, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningTokens: null, + }, + }); + } + if (!doneEmitted) { + output.emit({ + type: "done", + reason: hasTools ? "tool-use" : "stop", + }); + } + return; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (hasEmittedAnyChunk) { + throw lastError; + } + } + } + + if (lastError) throw lastError; +} + +async function invoke( + input: ProviderInvokeInput, + output: ProviderOutput, + context: PluginContext, +): Promise { + if (!input.resource) { + return { + status: "request-error", + message: "Add a Google Antigravity account or API key before calling Antigravity", + }; + } + let data: AccountData; + try { + data = accountData(input.resource); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return invalidResult(message, message); + } + + let patchData: AccountData | null = null; + + // Auto-refresh token if expired or close to expiration (skew 5 mins) + if (data.refreshToken && isTokenExpired(data)) { + try { + const refreshed = await refreshAccount(input.resource, context); + if (refreshed.privateData) { + data = refreshed.privateData as unknown as AccountData; + patchData = data; + } + } catch { + // Continue with existing token + } + } + + let projectId = data.projectId ?? "bamboo-precept-lgxtn"; + + try { + await streamCloudCode(data.accessToken, projectId, input.model.id, input, output, context); + return patchData + ? { status: "completed", patch: { privateData: patchData as unknown as JsonValue, state: { status: "ready" } } } + : { status: "completed" }; + } catch (error) { + if (error instanceof HttpError) { + if ((error.status === 401 || error.status === 403) && data.refreshToken && !isQuotaHttpError(error)) { + try { + const refreshed = await refreshAccount(input.resource, context); + if (refreshed.privateData) { + const freshData = refreshed.privateData as unknown as AccountData; + const freshProj = freshData.projectId ?? projectId; + await streamCloudCode(freshData.accessToken, freshProj, input.model.id, input, output, context); + return { + status: "completed", + patch: { privateData: freshData as unknown as JsonValue, state: { status: "ready" } }, + }; + } + } catch { + // Failed refresh + } + } + if (isQuotaHttpError(error)) { + return { + status: "resource-error", + message: error.message, + patch: quotaExhaustedPatch(data, error.body), + }; + } + return { status: "request-error", message: error.message }; + } + const message = error instanceof Error ? error.message : String(error); + if (isQuotaError(message)) { + return { status: "resource-error", message, patch: quotaExhaustedPatch(data, message) }; + } + return { status: "request-error", message }; + } +} + +export const antigravityProvider: ProviderSupport = { + id: "antigravity", + displayName: { + "en-US": "Google Antigravity", + "zh-CN": "Google Antigravity", + }, + description: { + "en-US": "Google Antigravity / Gemini model access with hybrid reasoning & agent tools.", + "zh-CN": "通过 Google Antigravity / Gemini API 使用混合推理与 Agent 工具。", + }, + providerType: "google", + resourceType: RESOURCE_TYPE, + models: antigravityModels, + invoke, +}; diff --git a/server/plugins/build-in/antigravity-auth/resources.ts b/server/plugins/build-in/antigravity-auth/resources.ts new file mode 100644 index 0000000..c240173 --- /dev/null +++ b/server/plugins/build-in/antigravity-auth/resources.ts @@ -0,0 +1,629 @@ +import type { JsonValue, PluginContext } from "cursor-byok:plugin"; +import type { + ResourceDraft, + ResourceImportFile, + ResourceImportResult, + ResourceImportSupport, + ResourceMetric, + ResourcePatch, + ResourceSnapshot, + ResourceState, + ResourceView, +} from "cursor-byok:resource"; +import { + ANTIGRAVITY_CLIENT_HEADERS, + ANTIGRAVITY_ENDPOINTS, + ANTIGRAVITY_USER_AGENT, +} from "./models.ts"; + +export const RESOURCE_TYPE = "antigravity-account"; + +const REFRESH_TOKEN_URL = "https://oauth2.googleapis.com/token"; + +export type QuotaMetric = { + remainingPercent: number; + resetAtMs: number | null; +}; + +export type AccountQuota = { + planLabel: string | null; + limitReached: boolean; + coolingUntilMs: number | null; + updatedAtMs: number; + claude?: QuotaMetric | null; + gemini?: QuotaMetric | null; +}; + +export type AccountData = { + accessToken: string; + refreshToken: string | null; + displayName: string; + projectId?: string | null; + expiresAtMs?: number | null; + quota: AccountQuota | null; +}; + +export type CredentialCandidate = { + accessToken: string; + refreshToken: string | null; + displayName: string | null; + projectId?: string | null; + expiresAtMs?: number | null; + quota?: AccountQuota | null; +}; + +export async function fetchAccountProjectAndTier( + accessToken: string, + network: PluginContext["network"], +): Promise<{ projectId: string; planLabel: string }> { + for (const endpoint of ANTIGRAVITY_ENDPOINTS) { + try { + const assistRes = await network.fetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "user-agent": ANTIGRAVITY_USER_AGENT, + ...ANTIGRAVITY_CLIENT_HEADERS, + }, + body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }), + }); + if (assistRes.status >= 200 && assistRes.status < 300) { + const body = object(JSON.parse(assistRes.body)); + const project = text(body?.cloudaicompanionProject); + const paid = object(body?.paidTier); + const current = object(body?.currentTier); + const tierName = text(paid?.name) ?? text(paid?.id) ?? text(current?.name) ?? text(current?.id); + let planLabel = "FREE"; + if (tierName) { + const lower = tierName.toLowerCase(); + if (lower.includes("ultra")) planLabel = "ULTRA"; + else if (lower.includes("pro") || lower.includes("premium") || lower.includes("advanced")) planLabel = "PRO"; + } + return { projectId: project ?? "bamboo-precept-lgxtn", planLabel }; + } + } catch { + // Continue next endpoint + } + } + return { projectId: "bamboo-precept-lgxtn", planLabel: "FREE" }; +} + +export async function queryAccountQuota( + accessToken: string, + network: PluginContext["network"], +): Promise<{ quota: AccountQuota | null; projectId: string }> { + const { projectId, planLabel } = await fetchAccountProjectAndTier(accessToken, network); + + for (const endpoint of ANTIGRAVITY_ENDPOINTS) { + try { + const response = await network.fetch(`${endpoint}/v1internal:fetchAvailableModels`, { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + accept: "application/json", + "user-agent": ANTIGRAVITY_USER_AGENT, + ...ANTIGRAVITY_CLIENT_HEADERS, + }, + body: JSON.stringify({ project: projectId }), + }); + if (response.status < 200 || response.status >= 300) continue; + const root = object(JSON.parse(response.body)); + const models = object(root?.models); + if (!models) continue; + + let claudeFraction: number | null = null; + let claudeResetAtMs: number | null = null; + let geminiFraction: number | null = null; + let geminiResetAtMs: number | null = null; + + for (const [key, value] of Object.entries(models)) { + const info = object(value); + const quota = object(info?.quotaInfo); + const fraction = typeof quota?.remainingFraction === "number" ? quota.remainingFraction : null; + const resetTime = text(quota?.resetTime); + const resetAtMs = resetTime ? Date.parse(resetTime) : null; + if (fraction === null) continue; + + const k = key.toLowerCase(); + if (k.includes("claude") || k.includes("sonnet") || k.includes("opus")) { + if (claudeFraction === null || fraction < claudeFraction) { + claudeFraction = fraction; + claudeResetAtMs = resetAtMs; + } + } else if (k.includes("gemini") || k.includes("flash") || k.includes("pro")) { + if (geminiFraction === null || fraction < geminiFraction) { + geminiFraction = fraction; + geminiResetAtMs = resetAtMs; + } + } + } + + return { + projectId, + quota: { + planLabel, + limitReached: false, + coolingUntilMs: null, + updatedAtMs: Date.now(), + claude: claudeFraction !== null ? { remainingPercent: Math.round(claudeFraction * 100), resetAtMs: claudeResetAtMs } : null, + gemini: geminiFraction !== null ? { remainingPercent: Math.round(geminiFraction * 100), resetAtMs: geminiResetAtMs } : null, + }, + }; + } catch { + // Continue next endpoint + } + } + + return { + projectId, + quota: { + planLabel, + limitReached: false, + coolingUntilMs: null, + updatedAtMs: Date.now(), + claude: null, + gemini: null, + }, + }; +} + +function object(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function decodeJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length < 2) return null; + try { + const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); + return object(JSON.parse(new TextDecoder().decode(bytes))); + } catch { + return null; + } +} + +function claim(payload: Record | null, key: string): string | null { + return payload ? text(payload[key]) : null; +} + +export function isJwtExpired(token: string, bufferSeconds = 300): boolean { + if (token.startsWith("AIza") || !token.includes(".")) return false; + const payload = decodeJwtPayload(token); + if (!payload) return false; + const exp = typeof payload.exp === "number" ? payload.exp : null; + if (!exp) return false; + const nowSeconds = Math.floor(Date.now() / 1000); + return exp <= (nowSeconds + bufferSeconds); +} + +export function isTokenExpired(data: AccountData, bufferSeconds = 300): boolean { + if (!data.refreshToken) return false; + if (typeof data.expiresAtMs === "number" && data.expiresAtMs > 0) { + return Date.now() >= data.expiresAtMs - bufferSeconds * 1000; + } + return isJwtExpired(data.accessToken, bufferSeconds); +} + +async function tokenFingerprint(token: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); + return Array.from( + new Uint8Array(digest).slice(0, 8), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); +} + +export async function accountIdentity( + token: string, + providedDisplayName?: string | null, +): Promise<{ key: string; displayName: string }> { + const payload = decodeJwtPayload(token); + const email = claim(payload, "email"); + const sub = claim(payload, "sub"); + const name = claim(payload, "name") ?? claim(payload, "preferred_username"); + + const fingerprint = await tokenFingerprint(token); + const identity = (providedDisplayName && !providedDisplayName.includes("Antigravity")) + ? providedDisplayName + : (email ?? sub ?? fingerprint); + const displayName = providedDisplayName ?? email ?? name ?? (token.startsWith("AIza") ? `API Key (${fingerprint.slice(0, 6)})` : identity); + return { key: `antigravity:${identity}`, displayName }; +} + +export async function credentialDraft(credential: CredentialCandidate): Promise { + const identity = await accountIdentity(credential.accessToken, credential.displayName); + const data: AccountData = { + accessToken: credential.accessToken, + refreshToken: credential.refreshToken, + displayName: credential.displayName ?? identity.displayName, + projectId: credential.projectId ?? "bamboo-precept-lgxtn", + expiresAtMs: credential.expiresAtMs ?? (credential.refreshToken ? Date.now() + 3500 * 1000 : null), + quota: credential.quota ?? null, + }; + return { key: identity.key, privateData: data as unknown as JsonValue }; +} + +export function accountData(resource: ResourceSnapshot): AccountData { + const data = object(resource.privateData); + const accessToken = text(data?.accessToken); + if (!accessToken) throw new Error("Antigravity account resource is missing its access token"); + return { + accessToken, + refreshToken: text(data?.refreshToken), + displayName: text(data?.displayName) ?? "Antigravity account", + projectId: text(data?.projectId) ?? "bamboo-precept-lgxtn", + expiresAtMs: typeof data?.expiresAtMs === "number" ? data.expiresAtMs : null, + quota: (data?.quota ?? null) as AccountQuota | null, + }; +} + +export function accountHeaders(data: AccountData): Record { + return { + authorization: `Bearer ${data.accessToken}`, + accept: "application/json", + "user-agent": ANTIGRAVITY_USER_AGENT, + ...ANTIGRAVITY_CLIENT_HEADERS, + }; +} + +export function quotaState(quota: AccountQuota | null, nowMs = Date.now()): ResourceState { + if (!quota || !quota.limitReached) return { status: "ready" }; + const coolingUntil = quota.coolingUntilMs; + if (coolingUntil !== null && coolingUntil > nowMs) { + return { + status: "cooling", + retryAtMs: coolingUntil, + message: "Antigravity rate limit reached; cooling down", + }; + } + return { status: "ready" }; +} + +export function quotaExhaustedPatch( + data: AccountData, + error?: string, + nowMs = Date.now(), +): ResourcePatch { + let retryAfterMs = 60 * 1000; + if (error) { + const match = error.match(/retry(?:_after|\s+after)?\s*[:=]?\s*(\d+)/i); + if (match?.[1]) { + const parsed = Number(match[1]); + if (Number.isFinite(parsed) && parsed > 0) { + retryAfterMs = parsed > 10_000_000 ? parsed - nowMs : parsed * 1000; + } + } + } + const coolingUntilMs = nowMs + Math.max(5000, retryAfterMs); + const quota: AccountQuota = { + planLabel: data.quota?.planLabel ?? "Antigravity / Gemini", + limitReached: true, + coolingUntilMs, + updatedAtMs: nowMs, + claude: data.quota?.claude ?? null, + gemini: data.quota?.gemini ?? null, + }; + return { + privateData: { ...data, quota } as unknown as JsonValue, + state: quotaState(quota, nowMs), + }; +} + +export function presentAccount(resource: ResourceSnapshot): ResourceView { + const data = accountData(resource); + const metrics: ResourceMetric[] = []; + if (data.quota?.claude) { + metrics.push({ + id: "claude", + label: { "en-US": "Claude", "zh-CN": "Claude" }, + unit: "percent", + value: data.quota.claude.remainingPercent, + ...(data.quota.claude.resetAtMs ? { resetAtMs: data.quota.claude.resetAtMs } : {}), + }); + } + if (data.quota?.gemini) { + metrics.push({ + id: "gemini", + label: { "en-US": "Gemini", "zh-CN": "Gemini" }, + unit: "percent", + value: data.quota.gemini.remainingPercent, + ...(data.quota.gemini.resetAtMs ? { resetAtMs: data.quota.gemini.resetAtMs } : {}), + }); + } + return { + displayName: data.displayName, + ...(data.quota?.planLabel ? { description: data.quota.planLabel } : {}), + ...(metrics.length > 0 ? { metrics } : {}), + }; +} + +import { CLIENT_ID, CLIENT_SECRET } from "./oauth.ts"; + +export async function refreshAccount( + resource: ResourceSnapshot, + context: PluginContext, +): Promise { + const data = accountData(resource); + let accessToken = data.accessToken; + let refreshToken = data.refreshToken; + let projectId = data.projectId ?? "bamboo-precept-lgxtn"; + let expiresAtMs = data.expiresAtMs ?? null; + + if (refreshToken) { + const response = await context.network.fetch(REFRESH_TOKEN_URL, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + grant_type: "refresh_token", + refresh_token: refreshToken, + }).toString(), + }); + + if (response.status < 200 || response.status >= 300) { + const bodyText = response.body.toLowerCase(); + // Only mark invalid if token is revoked or client is invalid + if (bodyText.includes("invalid_grant") || bodyText.includes("unauthorized_client")) { + return { + state: { status: "invalid", message: "Google authorization expired or revoked; please sign in again" }, + }; + } + // On network glitches or temporary Google server errors, keep ready + return { + state: { status: "ready" }, + }; + } + + const body = object(JSON.parse(response.body)); + accessToken = text(body?.access_token) ?? accessToken; + refreshToken = text(body?.refresh_token) ?? refreshToken; + const expiresIn = typeof body?.expires_in === "number" ? body.expires_in : 3600; + expiresAtMs = Date.now() + expiresIn * 1000; + } + + // Fetch real-time quota and project ID + const result = await queryAccountQuota(accessToken, context.network); + projectId = result.projectId || projectId; + + const updatedData: AccountData = { + ...data, + accessToken, + refreshToken, + projectId, + expiresAtMs, + quota: result.quota ?? data.quota, + }; + return { + privateData: updatedData as unknown as JsonValue, + state: { status: "ready" }, + }; +} + +function firstText(source: Record, keys: string[]): string | null { + for (const key of keys) { + const value = text(source[key]); + if (value) return value; + } + return null; +} + +function collectCredentials(value: unknown, output: CredentialCandidate[]): void { + if (Array.isArray(value)) { + for (const item of value) collectCredentials(item, output); + return; + } + const item = object(value); + if (!item || item.disabled === true) return; + for (const key of ["accounts", "credentials", "items", "keys"]) { + if (Array.isArray(item[key])) { + collectCredentials(item[key], output); + return; + } + } + const tokens = object(item.tokens) ?? item; + let accessToken = firstText(tokens, [ + "access", + "accessToken", + "access_token", + "token", + "apiKey", + "api_key", + "key", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "ANTIGRAVITY_API_KEY", + ]) ?? firstText(item, [ + "access", + "accessToken", + "access_token", + "token", + "apiKey", + "api_key", + "key", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "ANTIGRAVITY_API_KEY", + ]); + const refreshToken = firstText(tokens, ["refresh", "refresh_token", "refreshToken"]) ?? + firstText(item, ["refresh", "refresh_token", "refreshToken"]); + const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ?? + firstText(tokens, ["email", "display_name", "displayName", "name"]); + const projectId = firstText(item, ["project", "projectId", "project_id", "cloudaicompanionProject"]) ?? + firstText(tokens, ["project", "projectId", "project_id", "cloudaicompanionProject"]); + + if (!accessToken && !refreshToken) return; + if (!accessToken && refreshToken) { + accessToken = refreshToken; + } + output.push({ accessToken, refreshToken, displayName, projectId }); +} + +import { REDIRECT_URI } from "./oauth.ts"; + +export async function parseCredentialFiles( + files: ResourceImportFile[], + network?: PluginContext["network"], +): Promise<{ + credentials: CredentialCandidate[]; + warnings: string[]; +}> { + const credentials: CredentialCandidate[] = []; + const warnings: string[] = []; + for (const file of files) { + const raw = file.content.trim(); + if (!raw) continue; + + // Check if user pasted the Google OAuth callback URL or raw auth code (4/0ATs...) + const authCodeMatch = raw.match(/code=([40][a-zA-Z0-9_\-%]+)/) || (raw.startsWith("4/") ? [null, raw] : null); + if (authCodeMatch?.[1]) { + const code = decodeURIComponent(authCodeMatch[1]); + try { + const fetcher = network ? (url: string, init: RequestInit) => network.fetch(url, init) : (url: string, init: RequestInit) => fetch(url, init).then(async (r) => ({ status: r.status, body: await r.text() })); + const response = await fetcher(REFRESH_TOKEN_URL, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + }).toString(), + }); + const tokenBody = object(JSON.parse(response.body)); + if (response.status >= 200 && response.status < 300 && text(tokenBody?.access_token)) { + credentials.push({ + accessToken: text(tokenBody?.access_token)!, + refreshToken: text(tokenBody?.refresh_token), + displayName: text(tokenBody?.email) ?? "Google Antigravity Account", + expiresAtMs: Date.now() + ((typeof tokenBody?.expires_in === "number" ? tokenBody.expires_in : 3600) * 1000), + }); + continue; + } + } catch { + // Fallback to normal parsing + } + } + + // Check if file is raw API key or JWT token string + if (raw.startsWith("AIza") || (raw.split(".").length === 3 && !raw.includes(" "))) { + credentials.push({ accessToken: raw, refreshToken: null, displayName: file.name }); + continue; + } + + // Try parsing as JSON + let content: unknown; + try { + content = JSON.parse(raw); + } catch { + const envMatch = raw.match(/(?:API_KEY|TOKEN|GEMINI_API_KEY|GOOGLE_API_KEY|ANTIGRAVITY_API_KEY)\s*=\s*["']?([^"'\r\n]+)/i); + if (envMatch?.[1]) { + credentials.push({ accessToken: envMatch[1].trim(), refreshToken: null, displayName: file.name }); + continue; + } + const keyMatch = raw.match(/AIza[0-9A-Za-z-_]{35}/); + if (keyMatch?.[0]) { + credentials.push({ accessToken: keyMatch[0], refreshToken: null, displayName: file.name }); + continue; + } + warnings.push(`${file.name}: not valid JSON or API key`); + continue; + } + + if (typeof content === "string") { + credentials.push({ accessToken: content.trim(), refreshToken: null, displayName: file.name }); + continue; + } + + const found: CredentialCandidate[] = []; + collectCredentials(content, found); + if (found.length === 0) { + warnings.push(`${file.name}: no Google/Antigravity API key or token found`); + continue; + } + for (const candidate of found) { + if (candidate.refreshToken && candidate.accessToken === candidate.refreshToken) { + try { + const fetcher = network + ? (url: string, init: RequestInit) => network.fetch(url, init) + : (url: string, init: RequestInit) => + fetch(url, init).then(async (r) => ({ status: r.status, body: await r.text() })); + const response = await fetcher(REFRESH_TOKEN_URL, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + grant_type: "refresh_token", + refresh_token: candidate.refreshToken, + }).toString(), + }); + const body = object(JSON.parse(response.body)); + if (response.status >= 200 && response.status < 300 && text(body?.access_token)) { + candidate.accessToken = text(body?.access_token)!; + candidate.refreshToken = text(body?.refresh_token) ?? candidate.refreshToken; + candidate.expiresAtMs = Date.now() + ((typeof body?.expires_in === "number" ? body.expires_in : 3600) * 1000); + } + } catch { + // Keep placeholder + } + } + credentials.push(candidate); + } + } + return { credentials, warnings }; +} + +export const credentialImport: ResourceImportSupport = { + displayName: { + "en-US": "Import Google / Antigravity Credentials", + "zh-CN": "导入 Google / Antigravity 凭证", + }, + description: { + "en-US": "Import a JSON, TXT, or Callback URL containing Antigravity tokens or Google API keys.", + "zh-CN": "导入包含 Antigravity Token、Google API Key 或授权回调 URL 的 JSON/TXT 文件。", + }, + accept: [".json", ".txt", ".key", ".env"], + multiple: true, + parse: async (files: ResourceImportFile[], context: PluginContext): Promise => { + const { credentials, warnings } = await parseCredentialFiles(files, context.network); + if (credentials.length === 0) { + throw new Error(warnings.join("; ") || "credential file does not contain a valid token, authorization code, or API key"); + } + const drafts = await Promise.all( + credentials.map(async (c) => { + try { + const res = await queryAccountQuota(c.accessToken, context.network); + c.quota = res.quota; + c.projectId = res.projectId; + } catch { + // ignore error + } + return credentialDraft(c); + }), + ); + return { + resources: drafts, + ...(warnings.length > 0 ? { warnings } : {}), + }; + }, +}; diff --git a/server/plugins/build-in/grok-auth/grok_test.ts b/server/plugins/build-in/grok-auth/grok_test.ts index 0907554..66618d7 100644 --- a/server/plugins/build-in/grok-auth/grok_test.ts +++ b/server/plugins/build-in/grok-auth/grok_test.ts @@ -277,9 +277,7 @@ Deno.test("invoke streams normalized events from the xAI Chat Completions API", ); assertEquals(result, { status: "completed" }); const body = JSON.parse(requestBody) as Record; - assertEquals(body.model, "grok-4"); - assertEquals(body.stream, true); - assertEquals(body.prompt_cache_key, "conversation-1"); + assert(!("prompt_cache_key" in body), "standard OpenAI chat completion does not include prompt_cache_key"); assert(!("reasoning_effort" in body), "xAI endpoint rejects reasoning_effort"); assert(!("service_tier" in body), "xAI endpoint rejects service_tier"); assertEquals(requestHeaders["authorization"], `Bearer ${token}`); diff --git a/server/src/app.rs b/server/src/app.rs index d4ec4b1..9039ec5 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -125,6 +125,7 @@ impl App { self.registry.web_cache().set_service_addr(address); self.harness.set_backend_addr(address); tracing::info!(%address, "cursor server listening"); + tokio::spawn(crate::plugin::oauth_callback::start_oauth_callback_server(shutdown.clone())); let registry = self.registry; let harness = self.harness; let graceful = shutdown.clone(); diff --git a/server/src/control/mod.rs b/server/src/control/mod.rs index 542a45b..15d4ed8 100644 --- a/server/src/control/mod.rs +++ b/server/src/control/mod.rs @@ -138,6 +138,14 @@ pub fn api_router(service: ControlService) -> Router { .route("/__byok-api__/api/llm-calls", get(calls::list)) .route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail)) .route("/__byok-api__/api/plugins", get(plugins::list)) + .route( + "/__byok-api__/api/plugins/disabled-models", + get(plugins::get_disabled_models).put(plugins::set_disabled_models), + ) + .route( + "/__byok-api__/api/plugins/disabled-accounts", + get(plugins::get_disabled_accounts).put(plugins::set_disabled_accounts), + ) .route( "/__byok-api__/api/plugins/runtime", get(plugins::runtime_status) diff --git a/server/src/control/plugins.rs b/server/src/control/plugins.rs index 936ce55..fd93947 100644 --- a/server/src/control/plugins.rs +++ b/server/src/control/plugins.rs @@ -4,6 +4,7 @@ use axum::{ http::StatusCode, Json, }; +use serde::Deserialize; use crate::{ plugin::{ @@ -122,3 +123,45 @@ pub async fn cancel_runtime_initialization( ) -> Result> { Ok(Json(service.cancel_plugin_runtime_initialization())) } + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetDisabledModelsInput { + pub model_ids: Vec, +} + +pub async fn get_disabled_models( + State(service): State, +) -> Result>> { + Ok(Json(service.disabled_plugin_models().await?)) +} + +pub async fn set_disabled_models( + State(service): State, + Json(input): Json, +) -> Result>> { + service.set_disabled_plugin_models(input.model_ids).await?; + Ok(Json(service.disabled_plugin_models().await?)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetDisabledAccountsInput { + pub account_ids: Vec, +} + +pub async fn get_disabled_accounts( + State(service): State, +) -> Result>> { + Ok(Json(service.disabled_plugin_accounts().await?)) +} + +pub async fn set_disabled_accounts( + State(service): State, + Json(input): Json, +) -> Result>> { + service + .set_disabled_plugin_accounts(input.account_ids) + .await?; + Ok(Json(service.disabled_plugin_accounts().await?)) +} diff --git a/server/src/control/service.rs b/server/src/control/service.rs index 5c3fa02..08c9a16 100644 --- a/server/src/control/service.rs +++ b/server/src/control/service.rs @@ -251,6 +251,28 @@ impl ControlService { self.plugin_runtime.cancel_initialization() } + pub async fn disabled_plugin_models(&self) -> Result> { + let mut list: Vec<_> = self.store.disabled_plugin_models().await?.into_iter().collect(); + list.sort(); + Ok(list) + } + + pub async fn set_disabled_plugin_models(&self, model_ids: Vec) -> Result<()> { + let set = model_ids.into_iter().collect(); + self.store.set_disabled_plugin_models(&set).await + } + + pub async fn disabled_plugin_accounts(&self) -> Result> { + let mut list: Vec<_> = self.store.disabled_plugin_accounts().await?.into_iter().collect(); + list.sort(); + Ok(list) + } + + pub async fn set_disabled_plugin_accounts(&self, account_ids: Vec) -> Result<()> { + let set = account_ids.into_iter().collect(); + self.store.set_disabled_plugin_accounts(&set).await + } + pub(super) async fn ads( &self, disabled_ad_ids: Option<&str>, diff --git a/server/src/plugin/builtin.rs b/server/src/plugin/builtin.rs index 9ddd5ff..bd952bb 100644 --- a/server/src/plugin/builtin.rs +++ b/server/src/plugin/builtin.rs @@ -109,7 +109,63 @@ const GROK_AUTH: &[(&str, &str)] = &[ ), ]; -const PLUGINS: &[(&str, &[(&str, &str)])] = &[("codex-auth", CODEX_AUTH), ("grok-auth", GROK_AUTH)]; +const ANTIGRAVITY_AUTH: &[(&str, &str)] = &[ + ( + "plugin.json", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/plugins/build-in/antigravity-auth/plugin.json" + )), + ), + ( + "main.ts", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/plugins/build-in/antigravity-auth/main.ts" + )), + ), + ( + "provider.ts", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/plugins/build-in/antigravity-auth/provider.ts" + )), + ), + ( + "models.ts", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/plugins/build-in/antigravity-auth/models.ts" + )), + ), + ( + "oauth.ts", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/plugins/build-in/antigravity-auth/oauth.ts" + )), + ), + ( + "resources.ts", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/plugins/build-in/antigravity-auth/resources.ts" + )), + ), + ( + "assets/antigravity.svg", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/plugins/build-in/antigravity-auth/assets/antigravity.svg" + )), + ), +]; + +const PLUGINS: &[(&str, &[(&str, &str)])] = &[ + ("antigravity-auth", ANTIGRAVITY_AUTH), + ("codex-auth", CODEX_AUTH), + ("grok-auth", GROK_AUTH), +]; /// 把内置插件预装到 installed 目录。manifest 的 version 是缓存键: /// 版本一致时零写盘;版本变化时整目录同步并清理旧版本残留文件。 diff --git a/server/src/plugin/data.rs b/server/src/plugin/data.rs index f9205b1..9784d80 100644 --- a/server/src/plugin/data.rs +++ b/server/src/plugin/data.rs @@ -203,20 +203,20 @@ fn validate_component(value: &str, label: &str) -> Result<()> { Ok(()) } -fn set_directory_permissions(path: &Path) -> Result<()> { +fn set_directory_permissions(_path: &Path) -> Result<()> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o700))?; } Ok(()) } -fn set_file_permissions(path: &Path) -> Result<()> { +fn set_file_permissions(_path: &Path) -> Result<()> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600))?; } Ok(()) } diff --git a/server/src/plugin/mod.rs b/server/src/plugin/mod.rs index ce7faa3..5b39d92 100644 --- a/server/src/plugin/mod.rs +++ b/server/src/plugin/mod.rs @@ -7,6 +7,7 @@ mod definition; mod descriptor; mod installation; mod manifest; +pub mod oauth_callback; mod protocol; mod registry; mod runtime; diff --git a/server/src/plugin/oauth_callback.rs b/server/src/plugin/oauth_callback.rs new file mode 100644 index 0000000..348bf78 --- /dev/null +++ b/server/src/plugin/oauth_callback.rs @@ -0,0 +1,98 @@ +//! Lightweight local OAuth callback server for Google / Antigravity OAuth redirect flows. +use std::{collections::HashMap, net::SocketAddr, sync::Arc}; +use axum::{ + extract::{Query, State}, + response::Html, + routing::get, + Json, Router, +}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; + +#[derive(Default, Clone)] +pub struct OAuthCallbackState { + codes: Arc>>, +} + +#[derive(Deserialize)] +pub struct CallbackQuery { + pub code: Option, + pub state: Option, + pub error: Option, +} + +#[derive(Deserialize)] +pub struct StatusQuery { + pub state: Option, +} + +#[derive(Serialize)] +pub struct StatusResponse { + pub code: Option, +} + +pub async fn start_oauth_callback_server(shutdown: tokio_util::sync::CancellationToken) { + let port = 51121; + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let state = OAuthCallbackState::default(); + + let router = Router::new() + .route("/oauth-callback", get(handle_callback)) + .route("/auth-status", get(handle_status)) + .with_state(state); + + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(err) => { + tracing::warn!(%addr, %err, "OAuth callback port 51121 unavailable or already bound"); + return; + } + }; + tracing::info!(%addr, "OAuth callback server listening"); + + let server = axum::serve(listener, router).with_graceful_shutdown(async move { + shutdown.cancelled().await; + }); + + if let Err(err) = server.await { + tracing::debug!(%err, "OAuth callback server stopped"); + } +} + +async fn handle_callback( + State(state): State, + Query(query): Query, +) -> Html<&'static str> { + if let (Some(code), Some(st)) = (query.code, query.state) { + state.codes.write().insert(st, code); + } + Html(r#" + + + + Antigravity Authorization Successful + + + +
+
✓
+

Authorization Successful

+

Your Google Antigravity account has been authorized. You can close this browser tab and return to Cursor BYOK.

+
+ +"#) +} + +async fn handle_status( + State(state): State, + Query(query): Query, +) -> Json { + let code = query.state.and_then(|st| state.codes.read().get(&st).cloned()); + Json(StatusResponse { code }) +} diff --git a/server/src/plugin/registry.rs b/server/src/plugin/registry.rs index e107caf..6ebbdef 100644 --- a/server/src/plugin/registry.rs +++ b/server/src/plugin/registry.rs @@ -40,6 +40,7 @@ struct RegistryInner { entries: RwLock>>, workers: Mutex>>, oauth_sessions: Mutex>, + rr_counter: std::sync::atomic::AtomicUsize, } struct OAuthSession { @@ -108,6 +109,7 @@ impl PluginRegistry { entries: RwLock::new(None), workers: Mutex::new(HashMap::new()), oauth_sessions: Mutex::new(HashMap::new()), + rr_counter: std::sync::atomic::AtomicUsize::new(0), }), }) } @@ -142,6 +144,12 @@ impl PluginRegistry { let Some(executable) = self.inner.runtime.executable() else { return Vec::new(); }; + let disabled_models = self + .inner + .store + .disabled_plugin_models() + .await + .unwrap_or_default(); let mut models = Vec::new(); for entry in self.entries(&executable).await { for provider in &entry.definition.providers { @@ -154,14 +162,19 @@ impl PluginRegistry { .models(&entry.manifest.id, &provider.id) .await .unwrap_or_default(); - models.extend(stored.iter().map(|model| { - PluginModelDescriptor::new( + models.extend(stored.iter().filter_map(|model| { + let descriptor = PluginModelDescriptor::new( &entry.manifest.id, &entry.manifest.name, &entry.icon, provider, model, - ) + ); + if disabled_models.contains(&descriptor.id) { + None + } else { + Some(descriptor) + } })); } } @@ -192,6 +205,15 @@ impl PluginRegistry { } pub async fn plan_model(&self, model_id: &str) -> Result { + let disabled_models = self + .inner + .store + .disabled_plugin_models() + .await + .unwrap_or_default(); + if disabled_models.contains(model_id) { + return Err(Error::Provider(format!("plugin model '{model_id}' is disabled"))); + } let model = self.model_descriptor(model_id).await?; let request_url = format!("plugin://{}/{}", model.plugin_id, model.provider_id); Ok(PluginInvocationPlan { model, request_url }) @@ -704,13 +726,21 @@ impl PluginRegistry { } } match &provider.resource_type { - Some(resource_type) => !self - .inner - .state - .resources(plugin_id, resource_type) - .await - .unwrap_or_default() - .is_empty(), + Some(resource_type) => { + let disabled_accounts = self + .inner + .store + .disabled_plugin_accounts() + .await + .unwrap_or_default(); + let resources = self + .inner + .state + .resources(plugin_id, resource_type) + .await + .unwrap_or_default(); + resources.iter().any(|r| !disabled_accounts.contains(&r.id)) + } None => true, } } @@ -796,19 +826,54 @@ impl PluginRegistry { plugin_id: &str, resource_type: &str, ) -> Result { + let disabled_accounts = self + .inner + .store + .disabled_plugin_accounts() + .await + .unwrap_or_default(); let records = self.inner.state.resources(plugin_id, resource_type).await?; - if records.is_empty() { + let active_records: Vec<_> = records + .into_iter() + .filter(|record| !disabled_accounts.contains(&record.id)) + .collect(); + if active_records.is_empty() { return Err(Error::Provider(format!( - "plugin '{plugin_id}' has no '{resource_type}' resource; add one first" + "plugin '{plugin_id}' has no enabled '{resource_type}' resource; enable or add one first" ))); } let now = now_ms(); - records - .iter() - .find(|record| record.state.is_ready(now)) - .or_else(|| records.first()) - .cloned() - .ok_or_else(|| Error::Provider("no plugin resource is available".into())) + let mut ready_records: Vec<_> = active_records + .into_iter() + .filter(|record| record.state.is_ready(now)) + .collect(); + if ready_records.is_empty() { + return Err(Error::Provider(format!( + "all enabled accounts for plugin '{plugin_id}' are currently cooling or rate-limited" + ))); + } + + let get_priority = |r: &ResourceRecord| -> u8 { + let label = r.private_data.get("quota").and_then(|q| q.get("planLabel")).and_then(|l| l.as_str()).unwrap_or(""); + let lower = label.to_lowercase(); + if label.contains("🔥") || lower.contains("pro") || lower.contains("ultra") || lower.contains("premium") || lower.contains("advanced") { + 0 + } else { + 1 + } + }; + + ready_records.sort_by_key(|r| get_priority(r)); + if let Some(best_prio) = ready_records.first().map(|r| get_priority(r)) { + ready_records.retain(|r| get_priority(r) == best_prio); + } + + let index = self + .inner + .rr_counter + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + % ready_records.len(); + Ok(ready_records[index].clone()) } async fn find_record( diff --git a/server/src/plugin/sdk/protocol/openai_chat.ts b/server/src/plugin/sdk/protocol/openai_chat.ts index cd36bb9..7d615a3 100644 --- a/server/src/plugin/sdk/protocol/openai_chat.ts +++ b/server/src/plugin/sdk/protocol/openai_chat.ts @@ -102,7 +102,6 @@ export function buildChatBody(call: OpenAiChatCall): Record { body.reasoning_effort = call.request.reasoning.effort; } if (call.request.latency === "fast") body.service_tier = "fast"; - if (call.request.cacheKey !== null) body.prompt_cache_key = call.request.cacheKey; return { ...body, ...call.extraBody }; } diff --git a/server/src/plugin/worker.rs b/server/src/plugin/worker.rs index 2863b04..5fe11f6 100644 --- a/server/src/plugin/worker.rs +++ b/server/src/plugin/worker.rs @@ -425,9 +425,16 @@ impl HostContext { let raw_url = required_string(params, "url")?; let url = url::Url::parse(raw_url) .map_err(|error| Error::Config(format!("invalid plugin network URL: {error}")))?; - if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { + let is_loopback = url + .host_str() + .map(|h| h == "127.0.0.1" || h == "localhost") + .unwrap_or(false); + if (url.scheme() != "https" && (!is_loopback || url.scheme() != "http")) + || !url.username().is_empty() + || url.password().is_some() + { return Err(Error::Config( - "plugin network URL must be HTTPS without credentials".into(), + "plugin network URL must be HTTPS without credentials (or loopback HTTP)".into(), )); } let host = url diff --git a/server/src/store/settings.rs b/server/src/store/settings.rs index fac3bb2..4b532b5 100644 --- a/server/src/store/settings.rs +++ b/server/src/store/settings.rs @@ -1,4 +1,6 @@ //! Persists application settings. +use std::collections::HashSet; + use serde::{Deserialize, Serialize}; use crate::Result; @@ -10,6 +12,8 @@ const PROXY_SETTINGS_KEY: &str = "outbound_proxy"; const TAB_SETTINGS_KEY: &str = "cursor_tab"; const INSTALLATION_ID_KEY: &str = "installation_id"; const DESKTOP_SETTINGS_KEY: &str = "desktop_lifecycle"; +const DISABLED_PLUGIN_MODELS_KEY: &str = "disabled_plugin_models"; +const DISABLED_PLUGIN_ACCOUNTS_KEY: &str = "disabled_plugin_accounts"; pub const PUBLIC_TAB_SERVICE_URL: &str = "https://tab.leokun.cn"; @@ -299,4 +303,56 @@ impl Store { .await?; Ok(()) } + + pub async fn disabled_plugin_models(&self) -> Result> { + let value = sqlx::query_scalar::<_, String>( + "SELECT value_json FROM service_settings WHERE setting_key = ?", + ) + .bind(DISABLED_PLUGIN_MODELS_KEY) + .fetch_optional(&self.pool) + .await?; + value + .map(|value| serde_json::from_str(&value).map_err(Into::into)) + .unwrap_or_else(|| Ok(HashSet::new())) + } + + pub async fn set_disabled_plugin_models(&self, model_ids: &HashSet) -> Result<()> { + let value_json = serde_json::to_string(model_ids)?; + let _write = self.writes.lock().await; + sqlx::query( + "INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms", + ) + .bind(DISABLED_PLUGIN_MODELS_KEY) + .bind(value_json) + .bind(now_ms()) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn disabled_plugin_accounts(&self) -> Result> { + let value = sqlx::query_scalar::<_, String>( + "SELECT value_json FROM service_settings WHERE setting_key = ?", + ) + .bind(DISABLED_PLUGIN_ACCOUNTS_KEY) + .fetch_optional(&self.pool) + .await?; + value + .map(|value| serde_json::from_str(&value).map_err(Into::into)) + .unwrap_or_else(|| Ok(HashSet::new())) + } + + pub async fn set_disabled_plugin_accounts(&self, account_ids: &HashSet) -> Result<()> { + let value_json = serde_json::to_string(account_ids)?; + let _write = self.writes.lock().await; + sqlx::query( + "INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms", + ) + .bind(DISABLED_PLUGIN_ACCOUNTS_KEY) + .bind(value_json) + .bind(now_ms()) + .execute(&self.pool) + .await?; + Ok(()) + } }