diff --git a/src/components/providers/forms/KimiProviderForm.tsx b/src/components/providers/forms/KimiProviderForm.tsx index 957410bf6..381f7349a 100644 --- a/src/components/providers/forms/KimiProviderForm.tsx +++ b/src/components/providers/forms/KimiProviderForm.tsx @@ -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()); + const [expandedModels, setExpandedModels] = useState>( + () => 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}

)} -

{t("kimi.modelSelectionHint")}

-
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3fdd321a4..1bb906575 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b63b1d98e..b9e09f6ee 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": { diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 54a085759..18160345b 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -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": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index d4b9a65cb..c87a73db1 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0336b9105..bf0e573de 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "图片输入", diff --git a/tests/components/KimiProviderForm.test.tsx b/tests/components/KimiProviderForm.test.tsx index 74a2d4cf3..7de5713cc 100644 --- a/tests/components/KimiProviderForm.test.tsx +++ b/tests/components/KimiProviderForm.test.tsx @@ -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( + + {}} + /> + , + ); + 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", () => { /> , ); - 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"), {