mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
feat(kimi): lay out the provider editor like Pi and OpenCode
Models are now one row each (expand arrow, model ID, display name, delete) with capabilities and the context window in the expanded details, instead of one large card per model. Fields follow the same order as Pi and OpenCode (API format, API key, base URL), the API format menu shows readable protocol names, request headers are shown inline, and required fields use the shared asterisk style. The new display name column writes the model's display_name, which Kimi Code shows in /model; clearing it removes the field. The model selection hint moves into a "?" beside the models heading, and the Kimi-only labels that duplicated OpenCode's are removed from all locales.
This commit is contained in:
1 parent
d6db0c7570
commit
0f76829001
7 files changed
+291
-215
No files matched your search
@@ -1,18 +1,14 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react";
|
||||
import { ChevronRight, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Form, FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { HoverTip } from "@/components/ui/hover-tip";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,6 +26,7 @@ import {
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProvidersQuery } from "@/lib/query/queries";
|
||||
import type { ProviderFormData } from "@/lib/schemas/provider";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
@@ -48,8 +45,13 @@ import {
|
||||
findRequestHeaderValue,
|
||||
normalizeRequestHeaders,
|
||||
} from "./helpers/requestHeaders";
|
||||
import { BasicFormFields } from "./BasicFormFields";
|
||||
import { ApiKeySection, ModelDropdown } from "./shared";
|
||||
import { BasicFormFields, REQUIRED_LABEL } from "./BasicFormFields";
|
||||
import {
|
||||
ApiKeySection,
|
||||
EndpointField,
|
||||
GptContextWindowHint,
|
||||
ModelDropdown,
|
||||
} from "./shared";
|
||||
import {
|
||||
FetchedModelPicker,
|
||||
type FetchedModelSelection,
|
||||
@@ -76,6 +78,19 @@ const MODEL_FETCH_API_FORMATS: Partial<
|
||||
"google-genai": "google-generative-ai",
|
||||
};
|
||||
|
||||
// 和 Pi、OpenCode 的「接口格式」下拉用同一套叫法。
|
||||
const PROVIDER_TYPE_LABELS: Record<
|
||||
KimiProviderConfig["provider"]["type"],
|
||||
string
|
||||
> = {
|
||||
kimi: "Kimi",
|
||||
openai: "OpenAI Chat Completions",
|
||||
openai_responses: "OpenAI Responses",
|
||||
anthropic: "Anthropic Messages",
|
||||
"google-genai": "Google Generative AI",
|
||||
vertexai: "Google Vertex AI",
|
||||
};
|
||||
|
||||
const PRESETS = [
|
||||
{ id: KIMI_ACCOUNT_PRESET_ID, preset: kimiAccountPreset },
|
||||
...kimiProviderPresets.map((preset, index) => ({
|
||||
@@ -183,6 +198,15 @@ export function KimiProviderForm({
|
||||
};
|
||||
// 窗口还是占位值的行(这次新加、用户没改过窗口),选中模型时可以换成查到的值。
|
||||
const placeholderContextKeys = useRef(new Set<string>());
|
||||
const [expandedModels, setExpandedModels] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const toggleModelExpand = (key: string) =>
|
||||
setExpandedModels((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(key)) next.add(key);
|
||||
return next;
|
||||
});
|
||||
const modelKeyPrefix = providerKey || "custom";
|
||||
const uniqueModelKey = (models: KimiProviderConfig["models"], id: string) => {
|
||||
let key = `${modelKeyPrefix}/${id}`;
|
||||
@@ -197,13 +221,6 @@ export function KimiProviderForm({
|
||||
const { type: providerType, base_url: baseUrl = "" } = config.provider;
|
||||
const apiKey = config.provider.api_key ?? "";
|
||||
const headersKey = JSON.stringify(config.provider.custom_headers ?? {});
|
||||
// 请求头很少用到,收在高级选项里;已经有值(编辑已有卡、选了带请求头的预设)时展开。
|
||||
const hasCustomHeaders =
|
||||
Object.keys(config.provider.custom_headers ?? {}).length > 0;
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasCustomHeaders);
|
||||
useEffect(() => {
|
||||
if (hasCustomHeaders) setAdvancedExpanded(true);
|
||||
}, [hasCustomHeaders]);
|
||||
useEffect(() => {
|
||||
setFetchedModels((prev) => (prev.length === 0 ? prev : []));
|
||||
setIsFetchingModels(false);
|
||||
@@ -438,7 +455,6 @@ export function KimiProviderForm({
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-fg-2">{t("kimi.modelSelectionHint")}</p>
|
||||
<fieldset
|
||||
disabled={busy || !jsonValid}
|
||||
className="min-w-0 space-y-6 border-0 p-0 disabled:opacity-50"
|
||||
@@ -447,8 +463,8 @@ export function KimiProviderForm({
|
||||
form={form}
|
||||
beforeNameSlot={
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kimi-provider-key">
|
||||
{t("opencode.providerKey")} *
|
||||
<Label htmlFor="kimi-provider-key" className={REQUIRED_LABEL}>
|
||||
{t("opencode.providerKey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="kimi-provider-key"
|
||||
@@ -471,6 +487,9 @@ export function KimiProviderForm({
|
||||
placeholderContextKeys.current = new Set(
|
||||
[...placeholderContextKeys.current].map(rename),
|
||||
);
|
||||
setExpandedModels(
|
||||
(current) => new Set([...current].map(rename)),
|
||||
);
|
||||
setProviderKey(next);
|
||||
update({ ...config, models });
|
||||
}}
|
||||
@@ -491,7 +510,9 @@ export function KimiProviderForm({
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kimi-provider-type">{t("kimi.providerType")}</Label>
|
||||
<FormLabel htmlFor="kimi-provider-type">
|
||||
{t("opencode.npmPackage")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={config.provider.type}
|
||||
onValueChange={(type) =>
|
||||
@@ -506,21 +527,12 @@ export function KimiProviderForm({
|
||||
<SelectContent>
|
||||
{KIMI_PROVIDER_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
{PROVIDER_TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kimi-base-url">{t("kimi.baseUrl")} *</Label>
|
||||
<Input
|
||||
id="kimi-base-url"
|
||||
value={config.provider.base_url ?? ""}
|
||||
onChange={(e) => updateProvider({ base_url: e.target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<ApiKeySection
|
||||
id="kimi-api-key"
|
||||
value={config.provider.api_key ?? ""}
|
||||
@@ -532,38 +544,27 @@ export function KimiProviderForm({
|
||||
partnerPromotionKey={preset?.partnerPromotionKey}
|
||||
required={!config.provider.oauth}
|
||||
/>
|
||||
<Collapsible
|
||||
open={advancedExpanded}
|
||||
onOpenChange={setAdvancedExpanded}
|
||||
className="rounded-lg border border-border p-4"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={null}
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start gap-1.5 px-0 text-sm font-medium text-fg-1 hover:opacity-70 active:scale-100"
|
||||
>
|
||||
{advancedExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{t("providerForm.advancedOptionsToggle")}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<RequestHeadersEditor
|
||||
headers={config.provider.custom_headers ?? {}}
|
||||
onHeadersChange={(custom_headers) =>
|
||||
updateProvider({ custom_headers })
|
||||
}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<div className="space-y-3">
|
||||
<EndpointField
|
||||
id="kimi-base-url"
|
||||
label={t("opencode.baseUrl")}
|
||||
value={config.provider.base_url ?? ""}
|
||||
onChange={(base_url) => updateProvider({ base_url })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
<RequestHeadersEditor
|
||||
headers={config.provider.custom_headers ?? {}}
|
||||
onHeadersChange={(custom_headers) =>
|
||||
updateProvider({ custom_headers })
|
||||
}
|
||||
/>
|
||||
<div className="space-y-3 border-l border-border pl-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("kimi.models")}</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>{t("opencode.models")}</FormLabel>
|
||||
<HelpTip title={t("opencode.models")}>
|
||||
{t("kimi.modelSelectionHint")}
|
||||
</HelpTip>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<FetchedModelPicker
|
||||
models={fetchedModels}
|
||||
@@ -579,6 +580,7 @@ export function KimiProviderForm({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1"
|
||||
onClick={() => {
|
||||
let index = Object.keys(config.models).length + 1;
|
||||
let key = `${modelKeyPrefix}/model-${index}`;
|
||||
@@ -599,111 +601,177 @@ export function KimiProviderForm({
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("kimi.addModel")}
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("opencode.addModel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(config.models).map(([key, model]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="space-y-3 rounded-lg border border-border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm text-fg-2">{key}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("kimi.removeModel")}
|
||||
onClick={() => {
|
||||
const models = { ...config.models };
|
||||
delete models[key];
|
||||
placeholderContextKeys.current.delete(key);
|
||||
update({ ...config, models });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{Object.keys(config.models).length === 0 ? (
|
||||
<p className="py-2 text-sm text-fg-2">{t("opencode.noModels")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="mb-1 flex items-center gap-2 px-1 text-xs text-fg-2">
|
||||
<span className="w-9" />
|
||||
<span className={cn("flex-1", REQUIRED_LABEL)}>
|
||||
{t("opencode.modelId")}
|
||||
</span>
|
||||
<span className="flex-1">{t("opencode.modelName")}</span>
|
||||
<span className="w-9" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`kimi-model-${key}`}>
|
||||
{t("kimi.modelId")} *
|
||||
</Label>
|
||||
<div className="flex gap-1">
|
||||
{Object.entries(config.models).map(([key, model]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleModelExpand(key)}
|
||||
aria-label={t("opencode.toggleModelDetails")}
|
||||
aria-expanded={expandedModels.has(key)}
|
||||
className="h-9 w-9 shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform motion-reduce:transition-none",
|
||||
expandedModels.has(key) && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-1 gap-1">
|
||||
<Input
|
||||
id={`kimi-model-${key}`}
|
||||
value={model.model}
|
||||
onChange={(e) =>
|
||||
updateModel(key, { model: e.target.value })
|
||||
}
|
||||
placeholder="model-id"
|
||||
aria-label={t("opencode.modelId")}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) => {
|
||||
updateModel(key, { model: id });
|
||||
fillModelMetadataFor(key, id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id={`kimi-model-${key}`}
|
||||
value={model.model}
|
||||
id={`kimi-model-name-${key}`}
|
||||
value={model.display_name ?? ""}
|
||||
onChange={(e) =>
|
||||
updateModel(key, { model: e.target.value })
|
||||
updateModel(key, {
|
||||
display_name: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t("opencode.modelName")}
|
||||
aria-label={t("opencode.modelName")}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) => {
|
||||
updateModel(key, { model: id });
|
||||
fillModelMetadataFor(key, id);
|
||||
<HoverTip content={t("common.delete")}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("common.delete")}
|
||||
className="h-9 w-9 shrink-0 text-fg-2 hover:text-destructive"
|
||||
onClick={() => {
|
||||
const models = { ...config.models };
|
||||
delete models[key];
|
||||
placeholderContextKeys.current.delete(key);
|
||||
setExpandedModels((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
update({ ...config, models });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</HoverTip>
|
||||
</div>
|
||||
{expandedModels.has(key) && (
|
||||
<div className="ml-9 grid gap-3 border-l-2 border-muted pl-4 sm:grid-cols-2">
|
||||
<div className="flex min-h-9 flex-wrap items-center gap-x-8 gap-y-2 sm:col-span-2">
|
||||
{KIMI_CAPABILITIES.map((capability) => (
|
||||
<div
|
||||
key={capability}
|
||||
className="flex items-center gap-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Label
|
||||
htmlFor={`kimi-model-${capability}-${key}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t(`kimi.capabilities.${capability}`)}
|
||||
</Label>
|
||||
{CAPABILITIES_WITH_HELP.has(capability) && (
|
||||
<HelpTip
|
||||
title={t(`kimi.capabilities.${capability}`)}
|
||||
>
|
||||
{t(`kimi.capabilityHints.${capability}`)}
|
||||
</HelpTip>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id={`kimi-model-${capability}-${key}`}
|
||||
checked={
|
||||
model.capabilities?.includes(capability) ??
|
||||
false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
updateModel(key, {
|
||||
capabilities: checked
|
||||
? [
|
||||
...new Set([
|
||||
...(model.capabilities ?? []),
|
||||
capability,
|
||||
]),
|
||||
]
|
||||
: (model.capabilities ?? []).filter(
|
||||
(item) => item !== capability,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`kimi-context-${key}`}
|
||||
className={REQUIRED_LABEL}
|
||||
>
|
||||
{t("kimi.contextSize")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`kimi-context-${key}`}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={model.max_context_size}
|
||||
onChange={(e) => {
|
||||
placeholderContextKeys.current.delete(key);
|
||||
updateModel(key, {
|
||||
max_context_size: Number(e.target.value),
|
||||
});
|
||||
}}
|
||||
placeholder="262144"
|
||||
/>
|
||||
<GptContextWindowHint
|
||||
modelId={model.model}
|
||||
contextWindow={model.max_context_size}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`kimi-context-${key}`}>
|
||||
{t("kimi.contextSize")} *
|
||||
</Label>
|
||||
<Input
|
||||
id={`kimi-context-${key}`}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={model.max_context_size}
|
||||
onChange={(e) => {
|
||||
placeholderContextKeys.current.delete(key);
|
||||
updateModel(key, {
|
||||
max_context_size: Number(e.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{KIMI_CAPABILITIES.map((capability) => (
|
||||
<div key={capability} className="flex items-center gap-1">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={
|
||||
model.capabilities?.includes(capability) ?? false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
updateModel(key, {
|
||||
capabilities: checked
|
||||
? [
|
||||
...new Set([
|
||||
...(model.capabilities ?? []),
|
||||
capability,
|
||||
]),
|
||||
]
|
||||
: (model.capabilities ?? []).filter(
|
||||
(item) => item !== capability,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{t(`kimi.capabilities.${capability}`)}
|
||||
</label>
|
||||
{CAPABILITIES_WITH_HELP.has(capability) && (
|
||||
<HelpTip title={t(`kimi.capabilities.${capability}`)}>
|
||||
{t(`kimi.capabilityHints.${capability}`)}
|
||||
</HelpTip>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -4944,12 +4944,6 @@
|
||||
"help": "Add several Kimi coding plan accounts; each gets its own provider and models in Kimi Code. \"Use this account\" switches Kimi Code's default model to that account, and new sessions use it from then on. The account Kimi Code itself is signed in with shows up here automatically and is always used for web search and fetch; removing it here only hides it from the list and never signs Kimi Code out."
|
||||
},
|
||||
"modelSelectionHint": "Use the switch button on a provider card to select it. To choose another model, use /model in Kimi Code.",
|
||||
"providerType": "Provider type",
|
||||
"baseUrl": "Base URL",
|
||||
"models": "Models",
|
||||
"addModel": "Add model",
|
||||
"removeModel": "Remove model",
|
||||
"modelId": "Model ID",
|
||||
"contextSize": "Context size",
|
||||
"capabilities": {
|
||||
"image_in": "Image input",
|
||||
|
||||
@@ -4944,12 +4944,6 @@
|
||||
"help": "Kimi コーディングプランのアカウントを複数追加でき、それぞれ Kimi Code 内に専用のプロバイダーとモデルを持ちます。「このアカウントを使用」で Kimi Code の既定モデルをそのアカウントに切り替え、以降の新しいセッションで使われます。Kimi Code 自身がログインしているアカウントは自動的にここに表示され、Web 検索と取得には常にこれが使われます。ここで削除してもリストから外すだけで、Kimi Code はログアウトしません。"
|
||||
},
|
||||
"modelSelectionHint": "プロバイダーカードの切り替えボタンで既定のプロバイダーを選択します。別のモデルを選ぶには、Kimi Code で /model を使用してください。",
|
||||
"providerType": "プロバイダーの種類",
|
||||
"baseUrl": "リクエスト URL",
|
||||
"models": "モデル",
|
||||
"addModel": "モデルを追加",
|
||||
"removeModel": "モデルを削除",
|
||||
"modelId": "モデル ID",
|
||||
"contextSize": "コンテキスト長",
|
||||
"deleteSessionHint": "インデックスと履歴を同期するため、Kimi Code 内でセッションを削除してください。",
|
||||
"empty": {
|
||||
|
||||
@@ -4805,12 +4805,6 @@
|
||||
"managedHint": "Resmî Kimi hesaplarını Yetkilendirme Merkezi'nde yönetin."
|
||||
},
|
||||
"modelSelectionHint": "Sağlayıcıyı seçmek için karttaki geçiş düğmesini kullanın. Başka bir model seçmek için Kimi Code içinde /model komutunu kullanın.",
|
||||
"providerType": "Sağlayıcı türü",
|
||||
"baseUrl": "İstek adresi",
|
||||
"models": "Modeller",
|
||||
"addModel": "Model ekle",
|
||||
"removeModel": "Modeli kaldır",
|
||||
"modelId": "Model kimliği",
|
||||
"contextSize": "Bağlam uzunluğu",
|
||||
"deleteSessionHint": "Dizin ve geçmişin birlikte güncellenmesi için oturumu Kimi Code içinden silin.",
|
||||
"empty": {
|
||||
|
||||
@@ -4945,12 +4945,6 @@
|
||||
"help": "可新增多個 Kimi 程式設計方案帳號,每個帳號在 Kimi Code 中有自己的供應商和模型。「使用此帳號」會把 Kimi Code 的預設模型換成這個帳號的,之後新開的對話都用它。Kimi Code 自己登入的帳號會自動出現在這裡,聯網搜尋和網頁擷取始終用它;在這裡刪除它只會移出列表,不會讓 Kimi Code 登出。"
|
||||
},
|
||||
"modelSelectionHint": "點擊供應商卡片的切換按鈕選擇預設供應商;若要指定其他模型,請在 Kimi Code 中使用 /model。",
|
||||
"providerType": "供應商類型",
|
||||
"baseUrl": "請求位址",
|
||||
"models": "模型",
|
||||
"addModel": "新增模型",
|
||||
"removeModel": "移除模型",
|
||||
"modelId": "模型 ID",
|
||||
"contextSize": "上下文長度",
|
||||
"deleteSessionHint": "請在 Kimi Code 中刪除,以同時更新工作階段索引和歷史紀錄。",
|
||||
"empty": {
|
||||
|
||||
@@ -4944,12 +4944,6 @@
|
||||
"help": "可添加多个 Kimi 编程套餐账号,每个号在 Kimi Code 里有自己的供应商和模型。「使用此账号」会把 Kimi Code 的默认模型换成这个号的,之后新开的会话都用它。Kimi Code 自己登着的号会自动出现在这里,联网搜索和网页抓取始终用它;在这里删掉它只会移出列表,不会让 Kimi Code 退出登录。"
|
||||
},
|
||||
"modelSelectionHint": "点击供应商卡片的切换按钮选择默认供应商;需要指定其他模型时,在 Kimi Code 中使用 /model。",
|
||||
"providerType": "供应商类型",
|
||||
"baseUrl": "请求地址",
|
||||
"models": "模型",
|
||||
"addModel": "添加模型",
|
||||
"removeModel": "移除模型",
|
||||
"modelId": "模型 ID",
|
||||
"contextSize": "上下文长度",
|
||||
"capabilities": {
|
||||
"image_in": "图片输入",
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
@@ -57,6 +51,14 @@ const config = {
|
||||
},
|
||||
};
|
||||
|
||||
// 模型参数收在每行的展开区里,和 Pi、OpenCode 一样。
|
||||
function expandModels() {
|
||||
for (const toggle of screen.getAllByRole("button", {
|
||||
name: "opencode.toggleModelDetails",
|
||||
}))
|
||||
fireEvent.click(toggle);
|
||||
}
|
||||
|
||||
function setup(
|
||||
onSubmit = vi.fn(),
|
||||
category?: ProviderCategory,
|
||||
@@ -121,16 +123,17 @@ describe("KimiProviderForm", () => {
|
||||
target: { value: JSON.stringify(native) },
|
||||
});
|
||||
}
|
||||
expandModels();
|
||||
expect(
|
||||
screen.getByRole("checkbox", { name: "kimi.capabilities.image_in" }),
|
||||
screen.getByRole("switch", { name: "kimi.capabilities.image_in" }),
|
||||
).toBeChecked();
|
||||
expect(
|
||||
screen.queryByRole("checkbox", { name: "kimi.capabilities.audio_in" }),
|
||||
screen.queryByRole("switch", { name: "kimi.capabilities.audio_in" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("checkbox", { name: "kimi.capabilities.video_in" }),
|
||||
screen.queryByRole("switch", { name: "kimi.capabilities.video_in" }),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText(/kimi\.baseUrl/), {
|
||||
fireEvent.change(screen.getByLabelText("opencode.baseUrl"), {
|
||||
target: { value: "https://updated.example.com/v1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
@@ -154,8 +157,9 @@ describe("KimiProviderForm", () => {
|
||||
"future_capability",
|
||||
);
|
||||
const submit = setup(vi.fn(), undefined, native);
|
||||
expandModels();
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "kimi.capabilities.image_in" }),
|
||||
screen.getByRole("switch", { name: "kimi.capabilities.image_in" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
|
||||
@@ -202,10 +206,10 @@ describe("KimiProviderForm", () => {
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name }));
|
||||
expect(screen.getByLabelText("opencode.providerKey *")).toHaveValue(
|
||||
expect(screen.getByLabelText("opencode.providerKey")).toHaveValue(
|
||||
selected.providerKey,
|
||||
);
|
||||
expect(screen.getByLabelText("kimi.baseUrl *")).toHaveValue(
|
||||
expect(screen.getByLabelText("opencode.baseUrl")).toHaveValue(
|
||||
selected.settingsConfig.provider.base_url,
|
||||
);
|
||||
expect(
|
||||
@@ -262,7 +266,7 @@ describe("KimiProviderForm", () => {
|
||||
|
||||
it("edits native endpoints while preserving OAuth and model overrides", async () => {
|
||||
const submit = setup();
|
||||
fireEvent.change(screen.getByLabelText("kimi.baseUrl *"), {
|
||||
fireEvent.change(screen.getByLabelText("opencode.baseUrl"), {
|
||||
target: { value: "https://example.com/v1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
@@ -276,19 +280,52 @@ describe("KimiProviderForm", () => {
|
||||
expect(submit.mock.calls[0][0].providerKey).toBe("managed:kimi-code");
|
||||
});
|
||||
|
||||
it("keeps request headers in advanced options, open only when some are set", () => {
|
||||
it("shows request headers inline like Pi", () => {
|
||||
setup();
|
||||
expect(screen.getByDisplayValue("X-Custom")).toBeInTheDocument();
|
||||
cleanup();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "providerForm.advancedOptionsToggle",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const { custom_headers: _, ...provider } = config.provider;
|
||||
setup(vi.fn(), undefined, { ...config, provider });
|
||||
const toggle = screen.getByRole("button", {
|
||||
name: "providerForm.advancedOptionsToggle",
|
||||
});
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
it("flags a preset whose provider key collides with an existing card", async () => {
|
||||
const user = userEvent.setup();
|
||||
const selected = kimiProviderPresets.find(
|
||||
(preset) => preset.name === "Kimi",
|
||||
)!;
|
||||
server.use(
|
||||
http.post("http://tauri.local/get_providers", () =>
|
||||
HttpResponse.json({
|
||||
[selected.providerKey]: {
|
||||
id: selected.providerKey,
|
||||
name: "Kimi",
|
||||
settingsConfig: {},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<KimiProviderForm
|
||||
appId="kimi"
|
||||
submitLabel="Save"
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /^providerPreset\.group\.vendor / }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Kimi" }));
|
||||
expect(
|
||||
await screen.findByText("opencode.providerKeyDuplicate"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("opencode.providerKey")).toHaveValue(
|
||||
selected.providerKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks invalid JSON until the editor is corrected", () => {
|
||||
@@ -303,15 +340,16 @@ describe("KimiProviderForm", () => {
|
||||
|
||||
it("requires at least one model with a positive context length", () => {
|
||||
setup();
|
||||
fireEvent.change(screen.getByLabelText("kimi.contextSize *"), {
|
||||
expandModels();
|
||||
fireEvent.change(screen.getByLabelText("kimi.contextSize"), {
|
||||
target: { value: "0" },
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText("kimi.contextSize *"), {
|
||||
fireEvent.change(screen.getByLabelText("kimi.contextSize"), {
|
||||
target: { value: "262144" },
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "kimi.removeModel" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.delete" }));
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -355,10 +393,10 @@ describe("KimiProviderForm", () => {
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("opencode.providerKey *"), {
|
||||
fireEvent.change(screen.getByLabelText("opencode.providerKey"), {
|
||||
target: { value: "acme" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("kimi.baseUrl *"), {
|
||||
fireEvent.change(screen.getByLabelText("opencode.baseUrl"), {
|
||||
target: { value: "https://api.example.com/v1" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("API Key"), {
|
||||
|
||||
Reference in new issue
Block a user