diff --git a/messages/en-US.json b/messages/en-US.json index e0d18f68d..dff0cdcfd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1786,12 +1786,16 @@ "aiClientConfigDescriptionCodex": "OpenAI's agentic coding tool for the terminal.", "aiClientConfigDescriptionOpencode": "Open source terminal coding agent.", "aiClientConfigDescriptionCursor": "AI code editor built on VS Code.", + "aiClientConfigSetup": "Setup", "aiClientConfigTabCli": "Automatic (CLI)", "aiClientConfigTabManual": "Manual Configuration", + "aiClientConfigPreset": "Configuration", + "aiClientConfigStep": "Step {number}", "aiClientConfigEndpointPlaceholder": "https://example.resource.url.com", + "aiClientConfigPlaceholderWarning": "This snippet includes example values, such as a model name or resource URL. Replace them with your own before using it.", "aiClientConfigRevealError": "Could not load your API key.", "aiClientConfigRevealRetry": "Try again", - "resourceGeneralAiClientConfigLink": "See how to configure access to this resource in common clients like Claude Code and OpenCode", + "resourceGeneralAiClientConfigDescription": "This resource can be used from common clients like Claude Code and OpenCode. Learn more", "aiProvidersTitle": "AI Providers", "aiProvidersDescription": "Connect model providers for AI workloads in this organization", "aiProvidersBannerTitle": "Connect Model Providers", diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx index c56644a9e..67b0a3b83 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx @@ -28,6 +28,7 @@ import { createGeneralFormSchema } from "@app/lib/privateResourceForm"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; import Link from "next/link"; +import { ExternalLink } from "lucide-react"; import { useActionState, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -70,17 +71,26 @@ export default function PrivateResourceGeneralPage() { {t("privateResourceGeneralDescription")} + {siteResource.mode === "inference" ? ( + <> + {" "} + {t.rich( + "resourceGeneralAiClientConfigDescription", + { + configLink: (chunks) => ( + + {chunks} + + + ) + } + )} + + ) : null} - {siteResource.mode === "inference" ? ( -

- - {t("resourceGeneralAiClientConfigLink")} - -

- ) : null} diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx index af47de7bf..c92c5dcd4 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx @@ -50,6 +50,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; +import { ExternalLink } from "lucide-react"; import { build } from "@server/build"; import { TierFeature } from "@server/lib/billing/tierMatrix"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; @@ -255,17 +256,26 @@ export default function GeneralForm() { {t("resourceGeneralDescription")} + {resource.mode === "inference" ? ( + <> + {" "} + {t.rich( + "resourceGeneralAiClientConfigDescription", + { + configLink: (chunks) => ( + + {chunks} + + + ) + } + )} + + ) : null} - {resource.mode === "inference" ? ( -

- - {t("resourceGeneralAiClientConfigLink")} - -

- ) : null} diff --git a/src/components/ai-client-config/AiClientConfigCard.tsx b/src/components/ai-client-config/AiClientConfigCard.tsx index 8b6747896..d4c9c44e6 100644 --- a/src/components/ai-client-config/AiClientConfigCard.tsx +++ b/src/components/ai-client-config/AiClientConfigCard.tsx @@ -1,25 +1,13 @@ "use client"; -import { AiConfigCodeBlock } from "@app/components/ai-client-config/AiConfigCodeBlock"; +import { AiConfigBlocks } from "@app/components/ai-client-config/AiConfigBlocks"; import { Button } from "@app/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@app/components/ui/collapsible"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger -} from "@app/components/ui/tabs"; +import { OptionSelect } from "@app/components/OptionSelect"; import type { AiClientAuthInput, AiClientId, @@ -43,6 +31,8 @@ type AiClientConfigCardProps = { resourceNiceId?: string; }; +type SetupMode = "cli" | "manual"; + export function AiClientConfigCard({ clientId, name, @@ -57,6 +47,7 @@ export function AiClientConfigCard({ const [resolvedIconSrc, setResolvedIconSrc] = useState(iconSrc.light); const [open, setOpen] = useState(false); const [presetId, setPresetId] = useState("default"); + const [setupMode, setSetupMode] = useState("cli"); const [revealedKey, setRevealedKey] = useState(null); const [revealing, setRevealing] = useState(false); const [revealError, setRevealError] = useState(false); @@ -120,29 +111,24 @@ export function AiClientConfigCard({ const manualContent = guide ? (
{guide.presets.length > 1 ? ( - + + label={t("aiClientConfigPreset")} + options={guide.presets.map((p) => ({ + value: p.id, + label: p.label + }))} + value={preset?.id ?? presetId} + onChange={setPresetId} + cols={2} + /> + ) : null} + {preset ? ( + ) : null} -
- {preset?.blocks.map((block) => ( - - ))} -
) : null; @@ -191,35 +177,32 @@ export function AiClientConfigCard({ ) : null} {guide ? ( guide.cli ? ( - - - - {t("aiClientConfigTabCli")} - - - {t("aiClientConfigTabManual")} - - - - + + label={t("aiClientConfigSetup")} + options={[ + { + value: "cli", + label: t("aiClientConfigTabCli") + }, + { + value: "manual", + label: t("aiClientConfigTabManual") + } + ]} + value={setupMode} + onChange={setSetupMode} + cols={2} + /> + {setupMode === "cli" ? ( + - {guide.cli.configureWithKey ? ( - - ) : null} - - - {manualContent} - - + ) : ( + manualContent + )} + ) : ( manualContent ) diff --git a/src/components/ai-client-config/AiConfigBlocks.tsx b/src/components/ai-client-config/AiConfigBlocks.tsx new file mode 100644 index 000000000..1d919d031 --- /dev/null +++ b/src/components/ai-client-config/AiConfigBlocks.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { AiConfigCodeBlock } from "@app/components/ai-client-config/AiConfigCodeBlock"; +import { + OptionSelect, + type OptionSelectOption +} from "@app/components/OptionSelect"; +import type { AiConfigBlock, AiConfigRelation } from "@app/lib/aiClientConfig"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useState } from "react"; + +type AiConfigBlocksProps = { + blocks: AiConfigBlock[]; + relation: AiConfigRelation; +}; + +export function AiConfigBlocks({ blocks, relation }: AiConfigBlocksProps) { + const t = useTranslations(); + const showPicker = relation === "options" && blocks.length > 1; + const [selectedId, setSelectedId] = useState(blocks[0]?.id ?? ""); + + const methodOptions: OptionSelectOption[] = useMemo( + () => + blocks.map((block) => ({ + value: block.id, + label: block.label + })), + [blocks] + ); + + useEffect(() => { + if (!blocks.some((block) => block.id === selectedId)) { + setSelectedId(blocks[0]?.id ?? ""); + } + }, [blocks, selectedId]); + + if (blocks.length === 0) { + return null; + } + + if (showPicker) { + const selected = + blocks.find((block) => block.id === selectedId) ?? blocks[0]; + + return ( +
+ + +
+ ); + } + + const numberSteps = relation === "steps" && blocks.length > 1; + + return ( +
+ {blocks.map((block, index) => ( + + ))} +
+ ); +} diff --git a/src/components/ai-client-config/AiConfigCodeBlock.tsx b/src/components/ai-client-config/AiConfigCodeBlock.tsx index 95c088160..381071d5a 100644 --- a/src/components/ai-client-config/AiConfigCodeBlock.tsx +++ b/src/components/ai-client-config/AiConfigCodeBlock.tsx @@ -1,23 +1,53 @@ "use client"; import CopyTextBox from "@app/components/CopyTextBox"; -import type { AiConfigBlock } from "@app/lib/aiClientConfig"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { + aiConfigBlockHasPlaceholders, + type AiConfigBlock +} from "@app/lib/aiClientConfig"; import { cn } from "@app/lib/cn"; +import { AlertCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type AiConfigCodeBlockProps = { + block: AiConfigBlock; + step?: number; + hideLabel?: boolean; +}; + +export function AiConfigCodeBlock({ + block, + step, + hideLabel = false +}: AiConfigCodeBlockProps) { + const t = useTranslations(); + const label = + step != null + ? `${t("aiClientConfigStep", { number: step })}: ${block.label}` + : block.label; -export function AiConfigCodeBlock({ block }: { block: AiConfigBlock }) { return (
-

- {block.label} -

+ {!hideLabel ? ( +

+ {label} +

+ ) : null}
+ {aiConfigBlockHasPlaceholders(block) ? ( + + + + {t("aiClientConfigPlaceholderWarning")} + + + ) : null} string, auth: AiClientAuth, - kind: "code" | "steps" = "code" + kind: "code" | "steps" = "code", + hasPlaceholders = false ): AiConfigBlock { - return { id, label, kind, displayText: build(keyValue(auth)) }; + return { + id, + label, + kind, + displayText: build(keyValue(auth)), + hasPlaceholders + }; +} + +const EXAMPLE_RESOURCE_HOST = "example.resource.url.com"; + +export function aiConfigBlockHasPlaceholders(block: AiConfigBlock): boolean { + return ( + block.hasPlaceholders === true || + block.displayText.includes(EXAMPLE_RESOURCE_HOST) + ); } function buildCli( clientArg: "claude" | "codex" | "opencode", auth: AiClientAuth, resourceNiceId?: string -): AiCliCommands { - const resourceFlag = resourceNiceId - ? ` --resource ${resourceNiceId}` - : ""; +): AiConfigBlock[] { + const resourceFlag = resourceNiceId ? ` --resource ${resourceNiceId}` : ""; const configure: AiConfigBlock = { id: `cli-configure-${clientArg}`, @@ -73,17 +88,17 @@ function buildCli( }; if (auth.mode !== "keyed") { - return { configure }; + return [configure]; } - return { + return [ configure, - configureWithKey: { + { id: `cli-configure-key-${clientArg}`, label: "Configure with an API key", displayText: `pangolin configure ${clientArg} ${auth.key}${resourceFlag}` } - }; + ]; } function buildClaudeGuide( @@ -132,7 +147,9 @@ function buildClaudeGuide( " }", "}" ].join("\n"), - auth + auth, + "code", + true ); const vertexSettings = block( @@ -150,7 +167,9 @@ function buildClaudeGuide( " }", "}" ].join("\n"), - auth + auth, + "code", + true ); const kimiSettings = block( @@ -171,7 +190,9 @@ function buildClaudeGuide( " }", "}" ].join("\n"), - auth + auth, + "code", + true ); return { @@ -182,21 +203,25 @@ function buildClaudeGuide( { id: "default", label: "Default (Anthropic)", + relation: "options", blocks: [defaultSettings, defaultShell] }, { id: "bedrock", label: "Amazon Bedrock", + relation: "options", blocks: [bedrockSettings] }, { id: "vertex", label: "Google Vertex AI", + relation: "options", blocks: [vertexSettings] }, { id: "kimi", label: "Kimi K2 (Moonshot AI)", + relation: "options", blocks: [kimiSettings] } ] @@ -219,7 +244,9 @@ function buildCodexGuide( 'name = "Pangolin AI Gateway"', `base_url = "${endpoint}/v1"`, 'wire_api = "responses"', - ...(auth.mode === "keyed" ? ['env_key = "PANGOLIN_API_KEY"'] : []) + ...(auth.mode === "keyed" + ? ['env_key = "PANGOLIN_API_KEY"'] + : []) ].join("\n"), auth ); @@ -242,6 +269,7 @@ function buildCodexGuide( { id: "default", label: "Default", + relation: "steps", blocks: shell ? [settings, shell] : [settings] } ] @@ -255,7 +283,7 @@ function buildOpencodeGuide( ): AiClientGuide { const config = block( "opencode-config", - "Step 1: opencode.json", + "opencode.json", () => [ "{", @@ -279,11 +307,16 @@ function buildOpencodeGuide( const authFile = block( "opencode-auth", - "Step 2: auth.json", + "auth.json", (key) => - ["{", ' "anthropic": {', ' "type": "api",', ` "key": "${key}"`, " }", "}"].join( - "\n" - ), + [ + "{", + ' "anthropic": {', + ' "type": "api",', + ` "key": "${key}"`, + " }", + "}" + ].join("\n"), auth ); @@ -295,6 +328,7 @@ function buildOpencodeGuide( { id: "default", label: "Default", + relation: "steps", blocks: [config, authFile] } ] @@ -316,7 +350,8 @@ function buildCursorGuide(endpoint: string, auth: AiClientAuth): AiClientGuide { "5. Add a custom model matching the model your Pangolin AI Gateway serves (e.g. claude-sonnet-4-6)." ].join("\n"), auth, - "steps" + "steps", + true ); return { @@ -327,6 +362,7 @@ function buildCursorGuide(endpoint: string, auth: AiClientAuth): AiClientGuide { { id: "default", label: "Default", + relation: "steps", blocks: [steps] } ]