feat(opencode): Phase 9 - Frontend UI components for OpenCode

- Create OpenCodeFormFields.tsx with:
  - NPM package selector (from AI SDK ecosystem)
  - API Key input using shared ApiKeySection component
  - Base URL input (shown for openai-compatible)
  - Dynamic models editor (add/remove models)

- Update ProviderForm.tsx:
  - Import OpenCode presets and form fields
  - Add OPENCODE_DEFAULT_CONFIG constant
  - Add OpenCode to PresetEntry type union
  - Add OpenCode preset entries in useMemo
  - Add OpenCode state hooks (npm, apiKey, baseUrl, models)
  - Add OpenCode change handlers syncing to form
  - Add OpenCodeFormFields rendering section
  - Add OpenCode config editor using CommonConfigEditor

- Update ProviderActions.tsx for OpenCode additive mode:
  - Add appId and isInConfig props
  - Implement "Add to Config" / "Remove from Config" buttons
  - Disable failover mode for OpenCode
  - Update delete button logic for additive mode

- Update ProviderCard.tsx:
  - Pass appId and isInConfig to ProviderActions

- Update AddProviderDialog.tsx:
  - Add OpenCode base URL extraction from options.baseURL
This commit is contained in:
Jason
2026-01-15 16:38:26 +08:00
parent 093ff0ba29
commit 864884926a
5 changed files with 451 additions and 9 deletions
@@ -0,0 +1,221 @@
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Trash2 } from "lucide-react";
import { ApiKeySection } from "./shared";
import { opencodeNpmPackages } from "@/config/opencodeProviderPresets";
import type { ProviderCategory, OpenCodeModel } from "@/types";
interface OpenCodeFormFieldsProps {
// NPM Package
npm: string;
onNpmChange: (value: string) => void;
// API Key
apiKey: string;
onApiKeyChange: (value: string) => void;
category?: ProviderCategory;
shouldShowApiKeyLink: boolean;
websiteUrl: string;
// Base URL
baseUrl: string;
onBaseUrlChange: (value: string) => void;
// Models
models: Record<string, OpenCodeModel>;
onModelsChange: (models: Record<string, OpenCodeModel>) => void;
}
export function OpenCodeFormFields({
npm,
onNpmChange,
apiKey,
onApiKeyChange,
category,
shouldShowApiKeyLink,
websiteUrl,
baseUrl,
onBaseUrlChange,
models,
onModelsChange,
}: OpenCodeFormFieldsProps) {
const { t } = useTranslation();
// Add a new model entry
const handleAddModel = () => {
const newKey = `model-${Date.now()}`;
onModelsChange({
...models,
[newKey]: { name: "" },
});
};
// Remove a model entry
const handleRemoveModel = (key: string) => {
const newModels = { ...models };
delete newModels[key];
onModelsChange(newModels);
};
// Update model ID (key)
const handleModelIdChange = (oldKey: string, newKey: string) => {
if (oldKey === newKey || !newKey.trim()) return;
const newModels: Record<string, OpenCodeModel> = {};
for (const [k, v] of Object.entries(models)) {
if (k === oldKey) {
newModels[newKey] = v;
} else {
newModels[k] = v;
}
}
onModelsChange(newModels);
};
// Update model name
const handleModelNameChange = (key: string, name: string) => {
onModelsChange({
...models,
[key]: { ...models[key], name },
});
};
return (
<>
{/* NPM Package Selector */}
<div className="space-y-2">
<FormLabel htmlFor="opencode-npm">
{t("provider.form.opencode.npmPackage", {
defaultValue: "AI SDK Package",
})}
</FormLabel>
<Select value={npm} onValueChange={onNpmChange}>
<SelectTrigger id="opencode-npm">
<SelectValue
placeholder={t("provider.form.opencode.selectPackage", {
defaultValue: "Select a package",
})}
/>
</SelectTrigger>
<SelectContent>
{opencodeNpmPackages.map((pkg) => (
<SelectItem key={pkg.value} value={pkg.value}>
{pkg.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("provider.form.opencode.npmPackageHint", {
defaultValue:
"Select the AI SDK package that matches your provider.",
})}
</p>
</div>
{/* API Key */}
<ApiKeySection
value={apiKey}
onChange={onApiKeyChange}
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
/>
{/* Base URL (only for compatible providers) */}
{npm === "@ai-sdk/openai-compatible" && (
<div className="space-y-2">
<FormLabel htmlFor="opencode-baseurl">
{t("provider.form.opencode.baseUrl", { defaultValue: "Base URL" })}
</FormLabel>
<Input
id="opencode-baseurl"
value={baseUrl}
onChange={(e) => onBaseUrlChange(e.target.value)}
placeholder="https://api.example.com/v1"
/>
<p className="text-xs text-muted-foreground">
{t("provider.form.opencode.baseUrlHint", {
defaultValue:
"The base URL for OpenAI-compatible API endpoints.",
})}
</p>
</div>
)}
{/* Models Editor */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<FormLabel>
{t("provider.form.opencode.models", { defaultValue: "Models" })}
</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddModel}
className="h-7 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{t("provider.form.opencode.addModel", { defaultValue: "Add" })}
</Button>
</div>
{Object.keys(models).length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("provider.form.opencode.noModels", {
defaultValue: "No models configured. Click Add to add a model.",
})}
</p>
) : (
<div className="space-y-2">
{Object.entries(models).map(([key, model]) => (
<div key={key} className="flex items-center gap-2">
<Input
value={key}
onChange={(e) => handleModelIdChange(key, e.target.value)}
placeholder={t("provider.form.opencode.modelId", {
defaultValue: "Model ID",
})}
className="flex-1"
/>
<Input
value={model.name}
onChange={(e) => handleModelNameChange(key, e.target.value)}
placeholder={t("provider.form.opencode.modelName", {
defaultValue: "Display Name",
})}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveModel(key)}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("provider.form.opencode.modelsHint", {
defaultValue:
"Configure available models. Model ID is the API identifier, Display Name is shown in the UI.",
})}
</p>
</div>
</>
);
}