mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
Fix/opencode known field editors (#2907)
* fix: add opencode model limit editor * fix: add opencode headers editor * style: refine opencode advanced field layout * fix: preserve valid opencode headers * fix: preserve opencode extra options and sync translations --------- Co-authored-by: wzk <wx13571681304@outlook.com> Co-authored-by: Jason Young <44939412+farion1231@users.noreply.github.com> Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -3,6 +3,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -23,6 +28,8 @@ import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getModelExtraFields,
|
||||
isKnownModelKey,
|
||||
OPENCODE_EXTRA_OPTION_DRAFT_PREFIX,
|
||||
OPENCODE_HEADER_DRAFT_PREFIX,
|
||||
} from "./helpers/opencodeFormUtils";
|
||||
import type { ProviderCategory, OpenCodeModel } from "@/types";
|
||||
|
||||
@@ -72,19 +79,23 @@ function ExtraOptionKeyInput({
|
||||
optionKey,
|
||||
onChange,
|
||||
placeholder,
|
||||
placeholderPrefixes = [OPENCODE_EXTRA_OPTION_DRAFT_PREFIX],
|
||||
}: {
|
||||
optionKey: string;
|
||||
onChange: (newKey: string) => void;
|
||||
onChange: (newKey: string) => boolean | void;
|
||||
placeholder?: string;
|
||||
placeholderPrefixes?: string[];
|
||||
}) {
|
||||
// For new options with placeholder keys like "option-123", show empty string
|
||||
const displayValue = optionKey.startsWith("option-") ? "" : optionKey;
|
||||
const isPlaceholderKey = placeholderPrefixes.some((prefix) =>
|
||||
optionKey.startsWith(prefix),
|
||||
);
|
||||
const displayValue = isPlaceholderKey ? "" : optionKey;
|
||||
const [localValue, setLocalValue] = useState(displayValue);
|
||||
|
||||
// Sync when external key changes
|
||||
useEffect(() => {
|
||||
setLocalValue(optionKey.startsWith("option-") ? "" : optionKey);
|
||||
}, [optionKey]);
|
||||
setLocalValue(isPlaceholderKey ? "" : optionKey);
|
||||
}, [isPlaceholderKey, optionKey]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
@@ -93,7 +104,10 @@ function ExtraOptionKeyInput({
|
||||
onBlur={() => {
|
||||
const trimmed = localValue.trim();
|
||||
if (trimmed && trimmed !== optionKey) {
|
||||
onChange(trimmed);
|
||||
const accepted = onChange(trimmed);
|
||||
if (accepted === false) {
|
||||
setLocalValue(displayValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
@@ -160,6 +174,10 @@ interface OpenCodeFormFieldsProps {
|
||||
baseUrl: string;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
|
||||
// Headers
|
||||
headers: Record<string, string>;
|
||||
onHeadersChange: (headers: Record<string, string>) => void;
|
||||
|
||||
// Models
|
||||
models: Record<string, OpenCodeModel>;
|
||||
onModelsChange: (models: Record<string, OpenCodeModel>) => void;
|
||||
@@ -181,6 +199,8 @@ export function OpenCodeFormFields({
|
||||
partnerPromotionKey,
|
||||
baseUrl,
|
||||
onBaseUrlChange,
|
||||
headers,
|
||||
onHeadersChange,
|
||||
models,
|
||||
onModelsChange,
|
||||
extraOptions,
|
||||
@@ -190,6 +210,15 @@ export function OpenCodeFormFields({
|
||||
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
const [extraOptionsOpen, setExtraOptionsOpen] = useState(
|
||||
() => Object.keys(extraOptions).length > 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(extraOptions).length > 0) {
|
||||
setExtraOptionsOpen(true);
|
||||
}
|
||||
}, [extraOptions]);
|
||||
|
||||
const handleFetchModels = useCallback(() => {
|
||||
if (!baseUrl || !apiKey) {
|
||||
@@ -284,6 +313,77 @@ export function OpenCodeFormFields({
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelLimitChange = (
|
||||
modelKey: string,
|
||||
limitKey: "context" | "output",
|
||||
value: string,
|
||||
) => {
|
||||
const model = models[modelKey];
|
||||
const nextLimit = { ...(model.limit || {}) };
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue === "") {
|
||||
delete nextLimit[limitKey];
|
||||
} else {
|
||||
const parsed = Number(trimmedValue);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return;
|
||||
nextLimit[limitKey] = Math.trunc(parsed);
|
||||
}
|
||||
|
||||
const nextModel = { ...model };
|
||||
if (Object.keys(nextLimit).length > 0) {
|
||||
nextModel.limit = nextLimit;
|
||||
} else {
|
||||
delete nextModel.limit;
|
||||
}
|
||||
|
||||
onModelsChange({
|
||||
...models,
|
||||
[modelKey]: nextModel,
|
||||
});
|
||||
};
|
||||
|
||||
// Header handlers
|
||||
const handleAddHeader = () => {
|
||||
const newKey = `${OPENCODE_HEADER_DRAFT_PREFIX}${Date.now()}`;
|
||||
onHeadersChange({
|
||||
...headers,
|
||||
[newKey]: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveHeader = (key: string) => {
|
||||
const newHeaders = { ...headers };
|
||||
delete newHeaders[key];
|
||||
onHeadersChange(newHeaders);
|
||||
};
|
||||
|
||||
const handleHeaderKeyChange = (oldKey: string, newKey: string): boolean => {
|
||||
const trimmedKey = newKey.trim();
|
||||
if (!trimmedKey || oldKey === trimmedKey) return false;
|
||||
|
||||
const normalizedKey = trimmedKey.toLowerCase();
|
||||
const hasDuplicate = Object.keys(headers).some(
|
||||
(key) => key !== oldKey && key.toLowerCase() === normalizedKey,
|
||||
);
|
||||
if (hasDuplicate) return false;
|
||||
|
||||
const newHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key === oldKey) newHeaders[trimmedKey] = value;
|
||||
else newHeaders[key] = value;
|
||||
}
|
||||
onHeadersChange(newHeaders);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleHeaderValueChange = (key: string, value: string) => {
|
||||
onHeadersChange({
|
||||
...headers,
|
||||
[key]: value,
|
||||
});
|
||||
};
|
||||
|
||||
// Model options handlers
|
||||
const handleAddModelOption = (modelKey: string) => {
|
||||
const model = models[modelKey];
|
||||
@@ -410,7 +510,7 @@ export function OpenCodeFormFields({
|
||||
|
||||
// Extra Options handlers
|
||||
const handleAddExtraOption = () => {
|
||||
const newKey = `option-${Date.now()}`;
|
||||
const newKey = `${OPENCODE_EXTRA_OPTION_DRAFT_PREFIX}${Date.now()}`;
|
||||
onExtraOptionsChange({
|
||||
...extraOptions,
|
||||
[newKey]: "",
|
||||
@@ -506,82 +606,193 @@ export function OpenCodeFormFields({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Extra Options Editor */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
{t("opencode.extraOptions", { defaultValue: "额外选项" })}
|
||||
</FormLabel>
|
||||
{/* Headers Editor */}
|
||||
<div className="space-y-2 border-l border-border-default pl-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="max-w-3xl space-y-1">
|
||||
<FormLabel>
|
||||
{t("opencode.headers", { defaultValue: "Headers" })}
|
||||
</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.headersHint", {
|
||||
defaultValue:
|
||||
"Optional HTTP headers sent with provider requests, such as HTTP-Referer or X-Title.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddExtraOption}
|
||||
onClick={handleAddHeader}
|
||||
aria-label={t("opencode.addHeader", { defaultValue: "Add header" })}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("opencode.addExtraOption", { defaultValue: "添加" })}
|
||||
{t("opencode.addHeader", { defaultValue: "Add" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(extraOptions).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{t("opencode.noExtraOptions", {
|
||||
defaultValue: "暂无额外选项",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
|
||||
<span className="flex-1">
|
||||
{t("opencode.extraOptionKey", { defaultValue: "键名" })}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("opencode.extraOptionValue", { defaultValue: "值" })}
|
||||
</span>
|
||||
<span className="w-9" />
|
||||
</div>
|
||||
{Object.entries(extraOptions).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<ExtraOptionKeyInput
|
||||
optionKey={key}
|
||||
onChange={(newKey) => handleExtraOptionKeyChange(key, newKey)}
|
||||
placeholder={t("opencode.extraOptionKeyPlaceholder", {
|
||||
defaultValue: "timeout",
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
handleExtraOptionValueChange(key, e.target.value)
|
||||
}
|
||||
placeholder={t("opencode.extraOptionValuePlaceholder", {
|
||||
defaultValue: "600000",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveExtraOption(key)}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="max-w-3xl">
|
||||
{Object.keys(headers).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-1">
|
||||
{t("opencode.noHeaders", {
|
||||
defaultValue: "No custom headers configured",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
|
||||
<span className="flex-1">
|
||||
{t("opencode.headerName", { defaultValue: "Header" })}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("opencode.headerValue", { defaultValue: "Value" })}
|
||||
</span>
|
||||
<span className="w-9" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.extraOptionsHint", {
|
||||
defaultValue:
|
||||
"配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。",
|
||||
})}
|
||||
</p>
|
||||
{Object.entries(headers).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<ExtraOptionKeyInput
|
||||
optionKey={key}
|
||||
onChange={(newKey) => handleHeaderKeyChange(key, newKey)}
|
||||
placeholder={t("opencode.headerNamePlaceholder", {
|
||||
defaultValue: "X-Title",
|
||||
})}
|
||||
placeholderPrefixes={[OPENCODE_HEADER_DRAFT_PREFIX]}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
handleHeaderValueChange(key, e.target.value)
|
||||
}
|
||||
placeholder={t("opencode.headerValuePlaceholder", {
|
||||
defaultValue: "CC Switch",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveHeader(key)}
|
||||
aria-label={t("opencode.removeHeader", {
|
||||
defaultValue: "Remove header",
|
||||
})}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra Options Editor */}
|
||||
<Collapsible
|
||||
open={extraOptionsOpen}
|
||||
onOpenChange={setExtraOptionsOpen}
|
||||
className="space-y-2 border-l border-border-default pl-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 max-w-3xl flex-1 items-start gap-2 text-left"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
extraOptionsOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{t("opencode.extraOptions", {
|
||||
defaultValue: "Extra SDK Options",
|
||||
})}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("opencode.extraOptionsHint", {
|
||||
defaultValue:
|
||||
"Advanced SDK options not exposed by the structured fields.",
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setExtraOptionsOpen(true);
|
||||
handleAddExtraOption();
|
||||
}}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("opencode.addExtraOption", { defaultValue: "Add" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="max-w-3xl space-y-2">
|
||||
{Object.keys(extraOptions).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-1">
|
||||
{t("opencode.noExtraOptions", {
|
||||
defaultValue: "No extra SDK options configured",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 mb-1">
|
||||
<span className="flex-1">
|
||||
{t("opencode.extraOptionKey", { defaultValue: "Key" })}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("opencode.extraOptionValue", { defaultValue: "Value" })}
|
||||
</span>
|
||||
<span className="w-9" />
|
||||
</div>
|
||||
{Object.entries(extraOptions).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<ExtraOptionKeyInput
|
||||
optionKey={key}
|
||||
onChange={(newKey) =>
|
||||
handleExtraOptionKeyChange(key, newKey)
|
||||
}
|
||||
placeholder={t("opencode.extraOptionKeyPlaceholder", {
|
||||
defaultValue: "timeout",
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
handleExtraOptionValueChange(key, e.target.value)
|
||||
}
|
||||
placeholder={t("opencode.extraOptionValuePlaceholder", {
|
||||
defaultValue: "600000",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveExtraOption(key)}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Models Editor */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -644,6 +855,9 @@ export function OpenCodeFormFields({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleModelExpand(key)}
|
||||
aria-label={t("opencode.toggleModelDetails", {
|
||||
defaultValue: "Toggle model details",
|
||||
})}
|
||||
className="h-9 w-9 shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
@@ -690,6 +904,67 @@ export function OpenCodeFormFields({
|
||||
{/* Expanded model details */}
|
||||
{expandedModels.has(key) && (
|
||||
<div className="ml-9 pl-4 border-l-2 border-muted space-y-3">
|
||||
{/* Token limits (model.limit) */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("opencode.modelLimits", {
|
||||
defaultValue: "Token Limits",
|
||||
})}
|
||||
</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<FormLabel
|
||||
htmlFor={`opencode-${key}-limit-context`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("opencode.limitContext", {
|
||||
defaultValue: "Context",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`opencode-${key}-limit-context`}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={model.limit?.context ?? ""}
|
||||
onChange={(e) =>
|
||||
handleModelLimitChange(
|
||||
key,
|
||||
"context",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="1048576"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<FormLabel
|
||||
htmlFor={`opencode-${key}-limit-output`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("opencode.limitOutput", {
|
||||
defaultValue: "Output",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`opencode-${key}-limit-output`}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={model.limit?.output ?? ""}
|
||||
onChange={(e) =>
|
||||
handleModelLimitChange(
|
||||
key,
|
||||
"output",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="131072"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Properties (extra fields like variants, cost) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -2232,6 +2232,8 @@ function ProviderFormFull({
|
||||
partnerPromotionKey={opencodePartnerPromotionKey}
|
||||
baseUrl={opencodeForm.opencodeBaseUrl}
|
||||
onBaseUrlChange={opencodeForm.handleOpencodeBaseUrlChange}
|
||||
headers={opencodeForm.opencodeHeaders}
|
||||
onHeadersChange={opencodeForm.handleOpencodeHeadersChange}
|
||||
models={opencodeForm.opencodeModels}
|
||||
onModelsChange={opencodeForm.handleOpencodeModelsChange}
|
||||
extraOptions={opencodeForm.opencodeExtraOptions}
|
||||
|
||||
@@ -51,6 +51,11 @@ export const OPENCODE_KNOWN_OPTION_KEYS = [
|
||||
"headers",
|
||||
] as const;
|
||||
|
||||
// Contains ":", which is not valid in an HTTP field name, so it cannot
|
||||
// collide with a legitimate custom header from an existing configuration.
|
||||
export const OPENCODE_HEADER_DRAFT_PREFIX = "draft-header:";
|
||||
export const OPENCODE_EXTRA_OPTION_DRAFT_PREFIX = "draft-option:";
|
||||
|
||||
export const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
|
||||
{
|
||||
baseUrl: "",
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
|
||||
import {
|
||||
OPENCODE_DEFAULT_NPM,
|
||||
OPENCODE_DEFAULT_CONFIG,
|
||||
OPENCODE_EXTRA_OPTION_DRAFT_PREFIX,
|
||||
OPENCODE_HEADER_DRAFT_PREFIX,
|
||||
isKnownOpencodeOptionKey,
|
||||
parseOpencodeConfig,
|
||||
toOpencodeExtraOptions,
|
||||
@@ -24,11 +26,13 @@ export interface OpencodeFormState {
|
||||
opencodeNpm: string;
|
||||
opencodeApiKey: string;
|
||||
opencodeBaseUrl: string;
|
||||
opencodeHeaders: Record<string, string>;
|
||||
opencodeModels: Record<string, OpenCodeModel>;
|
||||
opencodeExtraOptions: Record<string, string>;
|
||||
handleOpencodeNpmChange: (npm: string) => void;
|
||||
handleOpencodeApiKeyChange: (apiKey: string) => void;
|
||||
handleOpencodeBaseUrlChange: (baseUrl: string) => void;
|
||||
handleOpencodeHeadersChange: (headers: Record<string, string>) => void;
|
||||
handleOpencodeModelsChange: (models: Record<string, OpenCodeModel>) => void;
|
||||
handleOpencodeExtraOptionsChange: (options: Record<string, string>) => void;
|
||||
resetOpencodeState: (config?: OpenCodeProviderConfig) => void;
|
||||
@@ -69,6 +73,16 @@ export function useOpencodeFormState({
|
||||
return typeof value === "string" ? value : "";
|
||||
});
|
||||
|
||||
const [opencodeHeaders, setOpencodeHeaders] = useState<
|
||||
Record<string, string>
|
||||
>(() => {
|
||||
if (appId !== "opencode") return {};
|
||||
const headers = initialOpencodeOptions.headers;
|
||||
return headers && typeof headers === "object"
|
||||
? (headers as Record<string, string>)
|
||||
: {};
|
||||
});
|
||||
|
||||
const [opencodeModels, setOpencodeModels] = useState<
|
||||
Record<string, OpenCodeModel>
|
||||
>(() => {
|
||||
@@ -128,6 +142,30 @@ export function useOpencodeFormState({
|
||||
[updateOpencodeSettings],
|
||||
);
|
||||
|
||||
const handleOpencodeHeadersChange = useCallback(
|
||||
(headers: Record<string, string>) => {
|
||||
setOpencodeHeaders(headers);
|
||||
updateOpencodeSettings((config) => {
|
||||
if (!config.options) config.options = {};
|
||||
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
const trimmedKey = key.trim();
|
||||
if (trimmedKey && !key.startsWith(OPENCODE_HEADER_DRAFT_PREFIX)) {
|
||||
nextHeaders[trimmedKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(nextHeaders).length > 0) {
|
||||
config.options.headers = nextHeaders;
|
||||
} else {
|
||||
delete config.options.headers;
|
||||
}
|
||||
});
|
||||
},
|
||||
[updateOpencodeSettings],
|
||||
);
|
||||
|
||||
const handleOpencodeModelsChange = useCallback(
|
||||
(models: Record<string, OpenCodeModel>) => {
|
||||
setOpencodeModels(models);
|
||||
@@ -152,7 +190,7 @@ export function useOpencodeFormState({
|
||||
|
||||
for (const [k, v] of Object.entries(options)) {
|
||||
const trimmedKey = k.trim();
|
||||
if (trimmedKey && !trimmedKey.startsWith("option-")) {
|
||||
if (trimmedKey && !k.startsWith(OPENCODE_EXTRA_OPTION_DRAFT_PREFIX)) {
|
||||
try {
|
||||
config.options[trimmedKey] = JSON.parse(v);
|
||||
} catch {
|
||||
@@ -170,6 +208,7 @@ export function useOpencodeFormState({
|
||||
setOpencodeNpm(config?.npm || OPENCODE_DEFAULT_NPM);
|
||||
setOpencodeBaseUrl(config?.options?.baseURL || "");
|
||||
setOpencodeApiKey(config?.options?.apiKey || "");
|
||||
setOpencodeHeaders(config?.options?.headers || {});
|
||||
setOpencodeModels(config?.models || {});
|
||||
setOpencodeExtraOptions(toOpencodeExtraOptions(config?.options || {}));
|
||||
}, []);
|
||||
@@ -180,11 +219,13 @@ export function useOpencodeFormState({
|
||||
opencodeNpm,
|
||||
opencodeApiKey,
|
||||
opencodeBaseUrl,
|
||||
opencodeHeaders,
|
||||
opencodeModels,
|
||||
opencodeExtraOptions,
|
||||
handleOpencodeNpmChange,
|
||||
handleOpencodeApiKeyChange,
|
||||
handleOpencodeBaseUrlChange,
|
||||
handleOpencodeHeadersChange,
|
||||
handleOpencodeModelsChange,
|
||||
handleOpencodeExtraOptionsChange,
|
||||
resetOpencodeState,
|
||||
|
||||
@@ -1359,6 +1359,7 @@
|
||||
"modelName": "Display Name",
|
||||
"noModels": "No models configured",
|
||||
"modelsRequired": "Please add at least one model",
|
||||
"toggleModelDetails": "Toggle model details",
|
||||
"providerKey": "Provider Key",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
|
||||
@@ -1366,6 +1367,15 @@
|
||||
"providerKeyRequired": "Provider key is required",
|
||||
"providerKeyDuplicate": "This key is already in use",
|
||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
||||
"headers": "Headers",
|
||||
"headersHint": "Optional HTTP headers sent with provider requests, such as HTTP-Referer or X-Title.",
|
||||
"addHeader": "Add header",
|
||||
"headerName": "Header",
|
||||
"headerValue": "Value",
|
||||
"headerNamePlaceholder": "X-Title",
|
||||
"headerValuePlaceholder": "CC Switch",
|
||||
"removeHeader": "Remove header",
|
||||
"noHeaders": "No custom headers configured",
|
||||
"extraOptions": "Extra Options",
|
||||
"extraOptionsHint": "Configure extra SDK options like timeout, setCacheKey, etc. Values are auto-parsed to appropriate types (number, boolean, etc.).",
|
||||
"addExtraOption": "Add",
|
||||
@@ -1375,6 +1385,9 @@
|
||||
"extraOptionValuePlaceholder": "600000",
|
||||
"noExtraOptions": "No extra options configured",
|
||||
"noModelOptions": "Model options, click + to add",
|
||||
"modelLimits": "Token Limits",
|
||||
"limitContext": "Context",
|
||||
"limitOutput": "Output",
|
||||
"modelExtraFields": "Model Properties",
|
||||
"noModelExtraFields": "Model properties (variants, cost, etc.), click + to add",
|
||||
"modelExtraFieldKeyPlaceholder": "variants",
|
||||
|
||||
@@ -1359,6 +1359,7 @@
|
||||
"modelName": "表示名",
|
||||
"noModels": "モデルが設定されていません",
|
||||
"modelsRequired": "モデルを少なくとも1つ追加してください",
|
||||
"toggleModelDetails": "モデル詳細を開閉",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。",
|
||||
@@ -1366,6 +1367,15 @@
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
|
||||
"headers": "ヘッダー",
|
||||
"headersHint": "HTTP-Referer や X-Title など、プロバイダーリクエストに付与する任意の HTTP ヘッダーです。",
|
||||
"addHeader": "ヘッダーを追加",
|
||||
"headerName": "ヘッダー",
|
||||
"headerValue": "値",
|
||||
"headerNamePlaceholder": "X-Title",
|
||||
"headerValuePlaceholder": "CC Switch",
|
||||
"removeHeader": "ヘッダーを削除",
|
||||
"noHeaders": "カスタムヘッダーはありません",
|
||||
"extraOptions": "追加オプション",
|
||||
"extraOptionsHint": "timeout、setCacheKey などの SDK オプションを設定。値は自動的に適切な型(数値、真偽値など)に変換されます。",
|
||||
"addExtraOption": "追加",
|
||||
@@ -1375,6 +1385,9 @@
|
||||
"extraOptionValuePlaceholder": "600000",
|
||||
"noExtraOptions": "追加オプションはありません",
|
||||
"noModelOptions": "モデルオプション、+ をクリックして追加",
|
||||
"modelLimits": "トークン制限",
|
||||
"limitContext": "コンテキスト",
|
||||
"limitOutput": "出力",
|
||||
"modelExtraFields": "モデルプロパティ",
|
||||
"noModelExtraFields": "モデルプロパティ(variants、cost など)、+ をクリックして追加",
|
||||
"modelExtraFieldKeyPlaceholder": "variants",
|
||||
|
||||
@@ -1331,6 +1331,7 @@
|
||||
"modelName": "顯示名稱",
|
||||
"noModels": "暫無模型設定",
|
||||
"modelsRequired": "請至少新增一個模型設定",
|
||||
"toggleModelDetails": "展開或收合模型詳細資料",
|
||||
"providerKey": "供應商標識",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號",
|
||||
@@ -1338,6 +1339,15 @@
|
||||
"providerKeyRequired": "請填寫供應商標識",
|
||||
"providerKeyDuplicate": "此標識已被使用,請更換",
|
||||
"providerKeyInvalid": "標識格式無效,只能使用小寫字母、數字和連字號",
|
||||
"headers": "請求標頭",
|
||||
"headersHint": "隨供應商請求傳送的選用 HTTP 請求標頭,例如 HTTP-Referer 或 X-Title。",
|
||||
"addHeader": "新增請求標頭",
|
||||
"headerName": "請求標頭",
|
||||
"headerValue": "值",
|
||||
"headerNamePlaceholder": "X-Title",
|
||||
"headerValuePlaceholder": "CC Switch",
|
||||
"removeHeader": "刪除請求標頭",
|
||||
"noHeaders": "暫無自訂請求標頭",
|
||||
"extraOptions": "額外選項",
|
||||
"extraOptionsHint": "設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。",
|
||||
"addExtraOption": "新增",
|
||||
@@ -1347,6 +1357,9 @@
|
||||
"extraOptionValuePlaceholder": "600000",
|
||||
"noExtraOptions": "暫無額外選項",
|
||||
"noModelOptions": "模型選項,點擊 + 新增",
|
||||
"modelLimits": "Token 限制",
|
||||
"limitContext": "上下文",
|
||||
"limitOutput": "輸出",
|
||||
"modelExtraFields": "模型屬性",
|
||||
"noModelExtraFields": "模型屬性 (variants, cost 等),點擊 + 新增",
|
||||
"modelExtraFieldKeyPlaceholder": "variants",
|
||||
|
||||
@@ -1359,6 +1359,7 @@
|
||||
"modelName": "显示名称",
|
||||
"noModels": "暂无模型配置",
|
||||
"modelsRequired": "请至少添加一个模型配置",
|
||||
"toggleModelDetails": "展开或收起模型详情",
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
|
||||
@@ -1366,6 +1367,15 @@
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
"headers": "请求头",
|
||||
"headersHint": "随供应商请求发送的可选 HTTP 请求头,如 HTTP-Referer 或 X-Title。",
|
||||
"addHeader": "添加请求头",
|
||||
"headerName": "请求头",
|
||||
"headerValue": "值",
|
||||
"headerNamePlaceholder": "X-Title",
|
||||
"headerValuePlaceholder": "CC Switch",
|
||||
"removeHeader": "删除请求头",
|
||||
"noHeaders": "暂无自定义请求头",
|
||||
"extraOptions": "额外选项",
|
||||
"extraOptionsHint": "配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。",
|
||||
"addExtraOption": "添加",
|
||||
@@ -1375,6 +1385,9 @@
|
||||
"extraOptionValuePlaceholder": "600000",
|
||||
"noExtraOptions": "暂无额外选项",
|
||||
"noModelOptions": "模型选项,点击 + 添加",
|
||||
"modelLimits": "Token 限制",
|
||||
"limitContext": "上下文",
|
||||
"limitOutput": "输出",
|
||||
"modelExtraFields": "模型属性",
|
||||
"noModelExtraFields": "模型属性 (variants, cost 等),点击 + 添加",
|
||||
"modelExtraFieldKeyPlaceholder": "variants",
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps, PropsWithChildren } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { OpenCodeFormFields } from "@/components/providers/forms/OpenCodeFormFields";
|
||||
import { Form } from "@/components/ui/form";
|
||||
|
||||
type OpenCodeFormFieldsProps = ComponentProps<typeof OpenCodeFormFields>;
|
||||
|
||||
const FormShell = ({ children }: PropsWithChildren) => {
|
||||
const form = useForm();
|
||||
|
||||
return <Form {...form}>{children}</Form>;
|
||||
};
|
||||
|
||||
const renderOpenCodeForm = (
|
||||
overrides: Partial<OpenCodeFormFieldsProps> = {},
|
||||
) => {
|
||||
const props: OpenCodeFormFieldsProps = {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
onNpmChange: vi.fn(),
|
||||
apiKey: "sk-test",
|
||||
onApiKeyChange: vi.fn(),
|
||||
category: "custom",
|
||||
shouldShowApiKeyLink: false,
|
||||
websiteUrl: "",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
onBaseUrlChange: vi.fn(),
|
||||
headers: {},
|
||||
onHeadersChange: vi.fn(),
|
||||
models: {
|
||||
"kimi-k2": {
|
||||
name: "Kimi K2",
|
||||
limit: { context: 1048576, output: 131072 },
|
||||
},
|
||||
},
|
||||
onModelsChange: vi.fn(),
|
||||
extraOptions: {},
|
||||
onExtraOptionsChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
props,
|
||||
...render(
|
||||
<FormShell>
|
||||
<OpenCodeFormFields {...props} />
|
||||
</FormShell>,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const expandFirstModel = () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Toggle model details" }));
|
||||
};
|
||||
|
||||
describe("OpenCodeFormFields", () => {
|
||||
it("surfaces existing provider headers", () => {
|
||||
renderOpenCodeForm({
|
||||
headers: {
|
||||
"HTTP-Referer": "https://cc-switch.app",
|
||||
"X-Title": "CC Switch",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue("HTTP-Referer")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByDisplayValue("https://cc-switch.app"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("X-Title")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("CC Switch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates provider headers", () => {
|
||||
const onHeadersChange = vi.fn();
|
||||
renderOpenCodeForm({
|
||||
headers: { "X-Title": "CC Switch" },
|
||||
onHeadersChange,
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("CC Switch"), {
|
||||
target: { value: "OpenCode" },
|
||||
});
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalledWith({
|
||||
"X-Title": "OpenCode",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a blank header name for newly added headers", () => {
|
||||
const onHeadersChange = vi.fn();
|
||||
const { rerender, props } = renderOpenCodeForm({ onHeadersChange });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add header" }));
|
||||
|
||||
const nextHeaders = onHeadersChange.mock.calls[0][0];
|
||||
const headerKey = Object.keys(nextHeaders)[0];
|
||||
expect(headerKey).toMatch(/^draft-header:/);
|
||||
|
||||
rerender(
|
||||
<FormShell>
|
||||
<OpenCodeFormFields {...props} headers={nextHeaders} />
|
||||
</FormShell>,
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText("X-Title")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("removes provider headers", () => {
|
||||
const onHeadersChange = vi.fn();
|
||||
renderOpenCodeForm({
|
||||
headers: { "X-Title": "CC Switch" },
|
||||
onHeadersChange,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove header" }));
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("rejects case-insensitive duplicate header names and restores the input", () => {
|
||||
const onHeadersChange = vi.fn();
|
||||
renderOpenCodeForm({
|
||||
headers: { "X-A": "A", "X-B": "B" },
|
||||
onHeadersChange,
|
||||
});
|
||||
|
||||
const keyInput = screen.getByDisplayValue("X-B");
|
||||
fireEvent.change(keyInput, { target: { value: "x-a" } });
|
||||
fireEvent.blur(keyInput);
|
||||
|
||||
expect(onHeadersChange).not.toHaveBeenCalled();
|
||||
expect(keyInput).toHaveValue("X-B");
|
||||
});
|
||||
|
||||
it("surfaces provider options whose names start with option-", () => {
|
||||
renderOpenCodeForm({
|
||||
extraOptions: { "option-mode": "legacy" },
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue("option-mode")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("legacy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces existing model token limits", () => {
|
||||
renderOpenCodeForm();
|
||||
|
||||
expandFirstModel();
|
||||
|
||||
expect(screen.getByLabelText("Context")).toHaveValue(1048576);
|
||||
expect(screen.getByLabelText("Output")).toHaveValue(131072);
|
||||
});
|
||||
|
||||
it("updates model token limits as structured numbers", () => {
|
||||
const onModelsChange = vi.fn();
|
||||
renderOpenCodeForm({ onModelsChange });
|
||||
|
||||
expandFirstModel();
|
||||
fireEvent.change(screen.getByLabelText("Context"), {
|
||||
target: { value: "2000000" },
|
||||
});
|
||||
|
||||
expect(onModelsChange).toHaveBeenCalledWith({
|
||||
"kimi-k2": {
|
||||
name: "Kimi K2",
|
||||
limit: { context: 2000000, output: 131072 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes model limit when both fields are cleared", () => {
|
||||
const onModelsChange = vi.fn();
|
||||
const { rerender, props } = renderOpenCodeForm({ onModelsChange });
|
||||
|
||||
expandFirstModel();
|
||||
fireEvent.change(screen.getByLabelText("Context"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
|
||||
const withoutContext = {
|
||||
"kimi-k2": {
|
||||
name: "Kimi K2",
|
||||
limit: { output: 131072 },
|
||||
},
|
||||
};
|
||||
expect(onModelsChange).toHaveBeenLastCalledWith(withoutContext);
|
||||
|
||||
rerender(
|
||||
<FormShell>
|
||||
<OpenCodeFormFields {...props} models={withoutContext} />
|
||||
</FormShell>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Output"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
|
||||
expect(onModelsChange).toHaveBeenLastCalledWith({
|
||||
"kimi-k2": {
|
||||
name: "Kimi K2",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useOpencodeFormState } from "@/components/providers/forms/hooks/useOpencodeFormState";
|
||||
|
||||
const renderOpencodeFormState = (
|
||||
initialSettingsConfig: Record<string, unknown>,
|
||||
) => {
|
||||
let settingsConfig = JSON.stringify(initialSettingsConfig);
|
||||
const onSettingsConfigChange = vi.fn((nextConfig: string) => {
|
||||
settingsConfig = nextConfig;
|
||||
});
|
||||
|
||||
const hook = renderHook(() =>
|
||||
useOpencodeFormState({
|
||||
appId: "opencode",
|
||||
initialData: { settingsConfig: initialSettingsConfig },
|
||||
onSettingsConfigChange,
|
||||
getSettingsConfig: () => settingsConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...hook,
|
||||
onSettingsConfigChange,
|
||||
getSettingsConfig: () => settingsConfig,
|
||||
};
|
||||
};
|
||||
|
||||
describe("useOpencodeFormState", () => {
|
||||
it("hydrates provider headers from options", () => {
|
||||
const { result } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
headers: {
|
||||
"HTTP-Referer": "https://cc-switch.app",
|
||||
"X-Title": "CC Switch",
|
||||
},
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
expect(result.current.opencodeHeaders).toEqual({
|
||||
"HTTP-Referer": "https://cc-switch.app",
|
||||
"X-Title": "CC Switch",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes provider headers to options", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeHeadersChange({
|
||||
"X-Title": "CC Switch",
|
||||
});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options.headers).toEqual({
|
||||
"X-Title": "CC Switch",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes options.headers when all provider headers are removed", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
headers: {
|
||||
"X-Title": "CC Switch",
|
||||
},
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeHeadersChange({});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves legitimate headers whose names start with header-", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
headers: {
|
||||
"header-version": "v1",
|
||||
"X-Title": "Old",
|
||||
},
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeHeadersChange({
|
||||
"header-version": "v1",
|
||||
"X-Title": "New",
|
||||
});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options.headers).toEqual({
|
||||
"header-version": "v1",
|
||||
"X-Title": "New",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legitimate options whose names start with option-", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
"option-mode": "legacy",
|
||||
timeout: 100,
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeExtraOptionsChange({
|
||||
"option-mode": "legacy",
|
||||
timeout: "200",
|
||||
"draft-option:123": "",
|
||||
});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options).toEqual({
|
||||
"option-mode": "legacy",
|
||||
timeout: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user