814 lines · 30.7 KB · TypeScript
RawDownload
"use client"; import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";import { useEffect, useMemo, useState } from "react";import {  isProviderDriverKind,  type ProviderInstanceConfig,  type ProviderInstanceEnvironmentVariable,  type ProviderInstanceId,  type ProviderDriverKind,  type ServerProvider,  type ServerProviderModel,} from "@t3tools/contracts"; import { cn } from "../../lib/utils";import { normalizeProviderAccentColor } from "../../providerInstances";import { Badge } from "../ui/badge";import { Button } from "../ui/button";import { Collapsible, CollapsibleContent } from "../ui/collapsible";import { DraftInput } from "../ui/draft-input";import { Switch } from "../ui/switch";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import type { DriverOption } from "./providerDriverMeta";import { ProviderModelsSection } from "./ProviderModelsSection";import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";import {  PROVIDER_STATUS_STYLES,  getProviderSummary,  getProviderVersionLabel,  type ProviderStatusKey,} from "./providerStatus"; const PROVIDER_ACCENT_SWATCHES = [  "#2563eb",  "#16a34a",  "#ea580c",  "#dc2626",  "#7c3aed",  "#0891b2",] as const; const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const REDACTED_EMAIL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; let environmentVariableDraftId = 0;const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; type EnvironmentDraftRow = {  readonly id: string;  readonly name: string;  readonly value: string;  readonly sensitive: boolean;  readonly valueRedacted?: boolean;}; function makeEnvironmentDraftRow(  variable: ProviderInstanceEnvironmentVariable,  index: number,): EnvironmentDraftRow {  return {    id: `${index}:${variable.name}`,    name: variable.name,    value: variable.value,    sensitive: variable.sensitive,    ...(variable.valueRedacted !== undefined ? { valueRedacted: variable.valueRedacted } : {}),  };} function redactedEmailPlaceholder(email: string): string {  let state = 0x811c9dc5;  for (let index = 0; index < email.length; index += 1) {    state ^= email.charCodeAt(index);    state = Math.imul(state, 0x01000193);  }   const nextChar = () => {    state = Math.imul(state ^ (state >>> 13), 0x85ebca6b);    state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35);    return REDACTED_EMAIL_ALPHABET[Math.abs(state) % REDACTED_EMAIL_ALPHABET.length] ?? "x";  };   return Array.from(email, (char) => {    if (char === "@" || char === "." || char === "-" || char === "_") return char;    return nextChar();  }).join("");} /** * Read a string value at `key` from the opaque per-driver config blob. * Returns an empty string when the key is missing or the stored value is * not a string. The permissive shape reflects that `config` is * `Schema.Unknown` at the contract boundary — forks may populate it with * non-string values that the built-in UI should round-trip without * throwing. */function readConfigString(config: unknown, key: string): string {  if (config === null || typeof config !== "object") return "";  const value = (config as Record<string, unknown>)[key];  return typeof value === "string" ? value : "";} /** * Read a string[] at `key` from the opaque config blob, filtering out * non-string entries. Used for `customModels`, which is always typed as * `string[]` by the concrete driver schemas but arrives here as * `Schema.Unknown`. */function readConfigStringArray(config: unknown, key: string): ReadonlyArray<string> {  if (config === null || typeof config !== "object") return [];  const value = (config as Record<string, unknown>)[key];  if (!Array.isArray(value)) return [];  return value.filter((entry): entry is string => typeof entry === "string");} /** * Produce the next config blob after setting `key` to `value`. Empty * strings drop the key so server defaults stay in effect, mirroring the * save-time normalization in `AddProviderInstanceDialog`. Returns * `undefined` when the resulting blob has no keys, which matches * `ProviderInstanceConfig.config` being optional. * * Non-string values already stored in the blob are carried through * verbatim so fork-owned fields survive edits made through this UI. */function nextConfigBlobWithString(  config: unknown,  key: string,  value: string,): Record<string, unknown> | undefined {  const base: Record<string, unknown> =    config !== null && typeof config === "object" ? { ...(config as Record<string, unknown>) } : {};  const trimmed = value.trim();  if (trimmed.length > 0) {    base[key] = value;  } else {    delete base[key];  }  return Object.keys(base).length > 0 ? base : undefined;} /** * Set `key` to an arbitrary value on the opaque config blob. Unlike * `nextConfigBlobWithString`, does not drop empty-looking values — the * caller is responsible for deciding whether an empty array / empty * object should be stored explicitly (e.g. `customModels: []` is a * meaningful "user cleared their custom list" state distinct from * "driver default"). */function nextConfigBlobWithValue(  config: unknown,  key: string,  value: unknown,): Record<string, unknown> {  const base: Record<string, unknown> =    config !== null && typeof config === "object" ? { ...(config as Record<string, unknown>) } : {};  base[key] = value;  return base;} export function deriveProviderModelsForDisplay(input: {  readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined;  readonly customModels: ReadonlyArray<string>;}): ReadonlyArray<ServerProviderModel> {  const liveCustomModelsBySlug = new Map(    (input.liveModels ?? [])      .filter((model) => model.isCustom)      .map((model) => [model.slug, model] as const),  );  const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? [];  const customModels = input.customModels.map(    (slug) =>      liveCustomModelsBySlug.get(slug) ?? {        slug,        name: slug,        isCustom: true,        capabilities: null,      },  );  return [...serverModels, ...customModels];} function ProviderAuthEmail(props: {  readonly email: string | undefined;  readonly prefix?: string;  readonly separator?: boolean;}) {  const [revealed, setRevealed] = useState(false);  const trimmed = props.email?.trim();  const redacted = useMemo(() => (trimmed ? redactedEmailPlaceholder(trimmed) : ""), [trimmed]);  if (!trimmed) return null;   return (    <span className="inline-flex min-w-0 items-center gap-1.5">      {props.separator ? <span aria-hidden>·</span> : null}      {props.prefix ? <span className="text-muted-foreground/80">{props.prefix}</span> : null}      <Tooltip>        <TooltipTrigger          render={            <button              type="button"              className={cn(                "min-w-0 cursor-pointer rounded-sm font-mono text-[11px] leading-none transition hover:text-foreground",                revealed ? "text-muted-foreground" : "select-none text-muted-foreground blur-[2px]",              )}              onClick={() => setRevealed((value) => !value)}              aria-label={revealed ? "Hide account email" : "Reveal account email"}            >              {revealed ? trimmed : redacted}            </button>          }        />        <TooltipPopup side="top">          {revealed ? "Click to hide email" : "Click to reveal email"}        </TooltipPopup>      </Tooltip>    </span>  );} function ProviderAccentColorPicker(props: {  readonly displayName: string;  readonly value: string | undefined;  readonly onCommit: (value: string) => void;}) {  const [draft, setDraft] = useState(props.value ?? "");  const [isEditing, setIsEditing] = useState(false);  const draftColor = normalizeProviderAccentColor(draft);   useEffect(() => {    if (isEditing) return;    setDraft(props.value ?? "");  }, [isEditing, props.value]);   const commitDraft = () => {    setIsEditing(false);    props.onCommit(draftColor ?? "");  };   const commitSwatch = (swatch: string) => {    setIsEditing(false);    setDraft(swatch);    props.onCommit(swatch);  };   return (    <div className="grid gap-2">      <span className="text-xs font-medium text-foreground">Accent color</span>      <div className="flex min-w-0 flex-wrap items-center gap-2">        <input          type="color"          value={draftColor ?? PROVIDER_ACCENT_SWATCHES[0]}          onFocus={() => setIsEditing(true)}          onInput={(event) => {            setIsEditing(true);            setDraft(event.currentTarget.value);          }}          onChange={(event) => {            setIsEditing(true);            setDraft(event.currentTarget.value);          }}          onBlur={commitDraft}          aria-label={`Accent color for ${props.displayName}`}          className="h-8 w-10 cursor-pointer rounded border border-input bg-background p-0.5"        />        <div className="flex flex-wrap gap-1.5">          {PROVIDER_ACCENT_SWATCHES.map((swatch) => {            const selected = draftColor?.toLowerCase() === swatch;            return (              <button                key={swatch}                type="button"                className={cn(                  "size-6 cursor-pointer rounded-full border transition",                  selected                    ? "border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"                    : "border-black/10 hover:scale-105 dark:border-white/20",                )}                style={{ backgroundColor: swatch }}                onClick={() => commitSwatch(swatch)}                aria-label={`Use ${swatch} accent`}              />            );          })}        </div>        {draftColor ? (          <Button            type="button"            size="sm"            variant="ghost"            className="h-7 px-2 text-xs text-muted-foreground"            onClick={() => {              setIsEditing(false);              setDraft("");              props.onCommit("");            }}          >            Clear          </Button>        ) : null}      </div>      <span className="text-xs text-muted-foreground">        Used to distinguish this instance in picker rails and model lists.      </span>    </div>  );} function ProviderEnvironmentSection(props: {  readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>;  readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void;}) {  const [rows, setRows] = useState<ReadonlyArray<EnvironmentDraftRow>>(() =>    props.environment.map(makeEnvironmentDraftRow),  );   useEffect(() => {    setRows(props.environment.map(makeEnvironmentDraftRow));  }, [props.environment]);   const publishRows = (nextRows: ReadonlyArray<EnvironmentDraftRow>) => {    const published: ProviderInstanceEnvironmentVariable[] = [];    for (const row of nextRows) {      const name = row.name.trim();      if (!ENVIRONMENT_VARIABLE_NAME_PATTERN.test(name)) {        if (          name.length > 0 ||          row.value.length > 0 ||          row.sensitive !== true ||          row.valueRedacted !== undefined        ) {          return;        }        continue;      }      const { id: _id, ...rest } = row;      published.push({ ...rest, name });    }    props.onChange(published);  };   const updateVariable = (id: string, patch: Partial<Omit<EnvironmentDraftRow, "id">>) => {    const nextRows = rows.map((row) =>      row.id === id        ? {            ...row,            ...patch,            ...(patch.value !== undefined ? { valueRedacted: false } : {}),          }        : row,    );    setRows(nextRows);    publishRows(nextRows);  };   const removeVariable = (id: string) => {    const nextRows = rows.filter((row) => row.id !== id);    setRows(nextRows);    publishRows(nextRows);  };   return (    <div className="grid gap-2">      <div className="flex items-center justify-between gap-3">        <span className="text-xs font-medium text-foreground">Environment variables</span>        <Button          type="button"          size="sm"          variant="outline"          className="h-7 gap-1.5 px-2 text-xs"          onClick={() =>            setRows([              ...rows,              {                id: nextEnvironmentVariableDraftId(),                name: "",                value: "",                sensitive: true,              },            ])          }        >          <PlusIcon className="size-3" />          Add        </Button>      </div>      {rows.length === 0 ? (        <p className="text-xs text-muted-foreground">          Add variables to pass API keys, base URLs, or other per-instance CLI settings.        </p>      ) : (        <div className="grid gap-2">          {rows.map((variable, index) => (            <div              key={variable.id}              className="grid gap-2 rounded-md border border-border/70 bg-muted/20 p-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_auto_auto] sm:items-center"            >              <DraftInput                value={variable.name}                onCommit={(name) => updateVariable(variable.id, { name: name.trim() })}                placeholder="VARIABLE_NAME"                spellCheck={false}                aria-label={`Environment variable name ${index + 1}`}              />              <DraftInput                value={variable.valueRedacted ? "" : variable.value}                onCommit={(value) => updateVariable(variable.id, { value })}                type={variable.sensitive ? "password" : undefined}                autoComplete="off"                placeholder={                  variable.valueRedacted ? "Stored secret - enter a new value to replace" : "Value"                }                spellCheck={false}                aria-label={`Environment variable value ${index + 1}`}              />              <label className="inline-flex h-8 items-center gap-2 text-xs text-muted-foreground">                <input                  type="checkbox"                  className="size-3.5"                  checked={variable.sensitive}                  onChange={(event) => {                    const sensitive = event.currentTarget.checked;                    updateVariable(variable.id, {                      sensitive,                      ...(sensitive && variable.valueRedacted === undefined                        ? {}                        : { valueRedacted: sensitive ? variable.valueRedacted : false }),                    });                  }}                />                Sensitive              </label>              <Button                type="button"                size="icon-sm"                variant="ghost"                className="size-8 justify-self-start text-muted-foreground hover:text-destructive sm:justify-self-end"                onClick={() => removeVariable(variable.id)}                aria-label={`Remove environment variable ${variable.name || index + 1}`}              >                <XIcon className="size-3.5" />              </Button>            </div>          ))}        </div>      )}      <span className="text-xs text-muted-foreground">        Sensitive values are stored separately and are not returned to the app after saving.      </span>    </div>  );} interface ProviderInstanceCardProps {  readonly instanceId: ProviderInstanceId;  readonly instance: ProviderInstanceConfig;  readonly driverOption: DriverOption | undefined;  readonly liveProvider: ServerProvider | undefined;  readonly isExpanded: boolean;  readonly onExpandedChange: (open: boolean) => void;  readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;  /**   * Pass `undefined` to hide the delete button entirely. Built-in default   * instance slots use `undefined` — they can't be deleted without losing   * the slot, and their "reset to defaults" affordance lives on an outer   * reset button instead. Explicit `| undefined` in the type accommodates   * `exactOptionalPropertyTypes: true`, where an absent key and   * `{ onDelete: undefined }` are treated as distinct shapes.   */  readonly onDelete?: (() => void) | undefined;  /**   * Optional outer reset button rendered next to the driver icon. Built-in   * default slots supply a reset-to-factory control here; custom instances   * omit it.   */  readonly headerAction?: React.ReactNode | undefined;  readonly hiddenModels: ReadonlyArray<string>;  readonly favoriteModels: ReadonlyArray<string>;  readonly modelOrder: ReadonlyArray<string>;  readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;  readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;  readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;} /** * A single configured provider-instance row in the Providers settings * section. Used for every row — both the built-in default instance for a * driver (rendered with `onDelete` omitted) and user-authored custom * instances (`onDelete` supplied). The only UI difference between the two * is whether the trash button is visible; every other field (display * name, config fields, models) behaves identically. * * Behavior notes: *   - `liveProvider` is matched by the caller via `instanceId`; when no *     match is available (e.g. the server hasn't probed yet, or the *     driver is not shipped by the current build) the card still renders *     with a neutral "checking" summary. *   - Unknown drivers (`driverOption === undefined`) get a read-only *     notice instead of editable fields, so fork instances round-trip *     without accidentally destroying their config.

Lines 1–500 of 814.