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
+171 -2
View File
@@ -20,6 +20,12 @@ import {
geminiProviderPresets,
type GeminiProviderPreset,
} from "@/config/geminiProviderPresets";
import {
opencodeProviderPresets,
type OpenCodeProviderPreset,
} from "@/config/opencodeProviderPresets";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import type { OpenCodeModel } from "@/types";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
@@ -62,9 +68,22 @@ const GEMINI_DEFAULT_CONFIG = JSON.stringify(
2,
);
const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
{
npm: "@ai-sdk/openai-compatible",
options: {
baseURL: "",
apiKey: "",
},
models: {},
},
null,
2,
);
type PresetEntry = {
id: string;
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset;
preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset | OpenCodeProviderPreset;
};
interface ProviderFormProps {
@@ -158,7 +177,9 @@ export function ProviderForm({
? CODEX_DEFAULT_CONFIG
: appId === "gemini"
? GEMINI_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
: appId === "opencode"
? OPENCODE_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "",
}),
@@ -328,6 +349,11 @@ export function ProviderForm({
id: `gemini-${index}`,
preset,
}));
} else if (appId === "opencode") {
return opencodeProviderPresets.map<PresetEntry>((preset, index) => ({
id: `opencode-${index}`,
preset,
}));
}
return providerPresets.map<PresetEntry>((preset, index) => ({
id: `claude-${index}`,
@@ -469,6 +495,106 @@ export function ProviderForm({
selectedPresetId: selectedPresetId ?? undefined,
});
// OpenCode 配置状态
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
if (appId !== "opencode") return "@ai-sdk/openai-compatible";
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.npm || "@ai-sdk/openai-compatible";
} catch {
return "@ai-sdk/openai-compatible";
}
});
const [opencodeApiKey, setOpencodeApiKey] = useState<string>(() => {
if (appId !== "opencode") return "";
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.options?.apiKey || "";
} catch {
return "";
}
});
const [opencodeBaseUrl, setOpencodeBaseUrl] = useState<string>(() => {
if (appId !== "opencode") return "";
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.options?.baseURL || "";
} catch {
return "";
}
});
const [opencodeModels, setOpencodeModels] = useState<Record<string, OpenCodeModel>>(() => {
if (appId !== "opencode") return {};
try {
const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG);
return config.models || {};
} catch {
return {};
}
});
// OpenCode handlers - sync state to form
const handleOpencodeNpmChange = useCallback(
(npm: string) => {
setOpencodeNpm(npm);
try {
const config = JSON.parse(form.watch("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
config.npm = npm;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpencodeApiKeyChange = useCallback(
(apiKey: string) => {
setOpencodeApiKey(apiKey);
try {
const config = JSON.parse(form.watch("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
if (!config.options) config.options = {};
config.options.apiKey = apiKey;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpencodeBaseUrlChange = useCallback(
(baseUrl: string) => {
setOpencodeBaseUrl(baseUrl);
try {
const config = JSON.parse(form.watch("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
if (!config.options) config.options = {};
config.options.baseURL = baseUrl.trim().replace(/\/+$/, "");
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpencodeModelsChange = useCallback(
(models: Record<string, OpenCodeModel>) => {
setOpencodeModels(models);
try {
const config = JSON.parse(form.watch("settingsConfig") || OPENCODE_DEFAULT_CONFIG);
config.models = models;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
const handleSubmit = (values: ProviderFormData) => {
@@ -941,6 +1067,23 @@ export function ProviderForm({
/>
)}
{/* OpenCode 专属字段 */}
{appId === "opencode" && (
<OpenCodeFormFields
npm={opencodeNpm}
onNpmChange={handleOpencodeNpmChange}
apiKey={opencodeApiKey}
onApiKeyChange={handleOpencodeApiKeyChange}
category={category}
shouldShowApiKeyLink={false}
websiteUrl=""
baseUrl={opencodeBaseUrl}
onBaseUrlChange={handleOpencodeBaseUrlChange}
models={opencodeModels}
onModelsChange={handleOpencodeModelsChange}
/>
)}
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
{appId === "codex" ? (
<>
@@ -1000,6 +1143,32 @@ export function ProviderForm({
)}
/>
</>
) : appId === "opencode" ? (
<>
<CommonConfigEditor
value={form.watch("settingsConfig")}
onChange={(config) => form.setValue("settingsConfig", config)}
useCommonConfig={false}
onCommonConfigToggle={() => {}}
commonConfigSnippet=""
onCommonConfigSnippetChange={() => {}}
commonConfigError=""
onEditClick={() => {}}
isModalOpen={false}
onModalClose={() => {}}
onExtract={() => Promise.resolve()}
isExtracting={false}
/>
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="space-y-0">
<FormMessage />
</FormItem>
)}
/>
</>
) : (
<>
<CommonConfigEditor