mirror of
https://github.com/mengxi-ream/read-frog.git
synced 2026-04-30 01:56:46 +00:00
fix(provider-options): require manual apply for recommendations (#1152)
Co-authored-by: ananaBMaster <68643891+ananaBMaster@users.noreply.github.com>
This commit is contained in:
parent
18c10b6b3e
commit
d3dc6bdc8b
19 changed files with 783 additions and 67 deletions
4
.changeset/five-apricots-compare.md
Normal file
4
.changeset/five-apricots-compare.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(provider-options): stop auto applying recommended provider options
|
||||
|
|
@ -8,10 +8,11 @@ import { useFieldContext } from "./form-context"
|
|||
type SelectFieldAutoSaveProps = React.ComponentProps<typeof Select> & {
|
||||
formForSubmit: { handleSubmit: () => void }
|
||||
label: React.ReactNode
|
||||
labelExtra?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SelectFieldAutoSave(
|
||||
{ formForSubmit, label, ...props }: SelectFieldAutoSaveProps,
|
||||
{ formForSubmit, label, labelExtra, ...props }: SelectFieldAutoSaveProps,
|
||||
) {
|
||||
const field = useFieldContext<string | undefined>()
|
||||
const errors = useStore(field.store, state => state.meta.errors)
|
||||
|
|
@ -26,9 +27,12 @@ export function SelectFieldAutoSave(
|
|||
|
||||
return (
|
||||
<Field invalid={hasError}>
|
||||
<FieldLabel nativeLabel={false} render={<div />}>
|
||||
{label}
|
||||
</FieldLabel>
|
||||
<div className="flex items-end justify-between w-full">
|
||||
<FieldLabel nativeLabel={false} render={<div />}>
|
||||
{label}
|
||||
</FieldLabel>
|
||||
{labelExtra}
|
||||
</div>
|
||||
<Select
|
||||
value={field.state.value}
|
||||
onValueChange={handleValueChange}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
// @vitest-environment jsdom
|
||||
import type { ReactNode } from "react"
|
||||
import type { APIProviderConfig } from "@/types/config/provider"
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { updateProviderConfig } from "@/utils/atoms/provider"
|
||||
import { formOpts, useAppForm } from "../form"
|
||||
import { ProviderOptionsField } from "../provider-options-field"
|
||||
|
||||
vi.mock("#imports", () => ({
|
||||
i18n: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/components/help-tooltip", () => ({
|
||||
HelpTooltip: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui/json-code-editor", () => ({
|
||||
JSONCodeEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label="provider-options-editor"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={event => onChange?.(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
const baseProviderConfig: APIProviderConfig = {
|
||||
id: "provider-1",
|
||||
name: "OpenAI",
|
||||
enabled: true,
|
||||
provider: "openai",
|
||||
model: {
|
||||
model: "gpt-5-mini",
|
||||
isCustomModel: false,
|
||||
customModel: null,
|
||||
},
|
||||
providerOptions: undefined,
|
||||
}
|
||||
|
||||
function ProviderOptionsFieldHarness({
|
||||
initialConfig,
|
||||
externalProviderOptions,
|
||||
}: {
|
||||
initialConfig: APIProviderConfig
|
||||
externalProviderOptions?: Record<string, unknown>
|
||||
}) {
|
||||
const [providerConfig, setProviderConfig] = useState(initialConfig)
|
||||
const form = useAppForm({
|
||||
...formOpts,
|
||||
defaultValues: providerConfig,
|
||||
onSubmit: async ({ value }) => {
|
||||
setProviderConfig(value)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(providerConfig)
|
||||
}, [providerConfig, form])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProviderOptionsField form={form} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (externalProviderOptions === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
setProviderConfig(updateProviderConfig(providerConfig, {
|
||||
providerOptions: externalProviderOptions,
|
||||
}) as APIProviderConfig)
|
||||
}}
|
||||
>
|
||||
apply-external
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe("providerOptionsField", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("preserves local formatting when a successful editor save echoes back through the form", async () => {
|
||||
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
|
||||
|
||||
const editor = screen.getByLabelText("provider-options-editor")
|
||||
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"minimal\"}")
|
||||
})
|
||||
|
||||
it("syncs the editor when an external update arrives, even if the saved value is unchanged", async () => {
|
||||
const externalProviderOptions = { enableThinking: false }
|
||||
|
||||
render(
|
||||
<ProviderOptionsFieldHarness
|
||||
initialConfig={{
|
||||
...baseProviderConfig,
|
||||
providerOptions: externalProviderOptions,
|
||||
}}
|
||||
externalProviderOptions={externalProviderOptions}
|
||||
/>,
|
||||
)
|
||||
|
||||
const editor = screen.getByLabelText("provider-options-editor")
|
||||
fireEvent.change(editor, { target: { value: "{" } })
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "apply-external" }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText("provider-options-editor")).toHaveValue(JSON.stringify(externalProviderOptions, null, 2))
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
// @vitest-environment jsdom
|
||||
import type { APIProviderConfig } from "@/types/config/provider"
|
||||
import { useStore } from "@tanstack/react-form"
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { formOpts, useAppForm } from "../form"
|
||||
import { TranslateModelSelector } from "../translate-model-selector"
|
||||
|
||||
vi.mock("#imports", () => ({
|
||||
i18n: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../components/provider-options-recommendation-trigger", () => ({
|
||||
ProviderOptionsRecommendationTrigger: ({
|
||||
currentProviderOptions,
|
||||
onApply,
|
||||
}: {
|
||||
currentProviderOptions?: Record<string, unknown>
|
||||
onApply: (options: Record<string, unknown>) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply({ reasoningEffort: "minimal" })}
|
||||
>
|
||||
apply-recommendation
|
||||
</button>
|
||||
<output aria-label="current-provider-options-prop">
|
||||
{JSON.stringify(currentProviderOptions ?? null)}
|
||||
</output>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const duplicateProviderName = "Duplicate provider"
|
||||
|
||||
const baseProviderConfig: APIProviderConfig = {
|
||||
id: "provider-1",
|
||||
name: "OpenAI",
|
||||
enabled: true,
|
||||
provider: "openai",
|
||||
model: {
|
||||
model: "gpt-5-mini",
|
||||
isCustomModel: true,
|
||||
customModel: "gpt-5-mini",
|
||||
},
|
||||
providerOptions: undefined,
|
||||
}
|
||||
|
||||
async function flushUpdates() {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
function TranslateModelSelectorHarness({
|
||||
initialConfig = baseProviderConfig,
|
||||
}: {
|
||||
initialConfig?: APIProviderConfig
|
||||
}) {
|
||||
const [providerConfig, setProviderConfig] = useState(initialConfig)
|
||||
const [submitCount, setSubmitCount] = useState(0)
|
||||
const form = useAppForm({
|
||||
...formOpts,
|
||||
defaultValues: providerConfig,
|
||||
onSubmit: async ({ value }) => {
|
||||
setSubmitCount(count => count + 1)
|
||||
setProviderConfig(value)
|
||||
},
|
||||
})
|
||||
const formValues = useStore(form.store, state => state.values)
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(providerConfig)
|
||||
}, [providerConfig, form])
|
||||
|
||||
return (
|
||||
<form.AppForm>
|
||||
<form.AppField
|
||||
name="name"
|
||||
validators={{
|
||||
onChange: ({ value }) => value === duplicateProviderName ? "Duplicate provider name" : undefined,
|
||||
}}
|
||||
>
|
||||
{field => (
|
||||
<input
|
||||
aria-label="provider-name"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => {
|
||||
field.handleChange(event.target.value)
|
||||
void form.handleSubmit()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
<TranslateModelSelector form={form} />
|
||||
<output aria-label="form-name">{formValues.name}</output>
|
||||
<output aria-label="form-provider-options">{JSON.stringify(formValues.providerOptions ?? null)}</output>
|
||||
<output aria-label="persisted-name">{providerConfig.name}</output>
|
||||
<output aria-label="persisted-provider-options">{JSON.stringify(providerConfig.providerOptions ?? null)}</output>
|
||||
<output aria-label="submit-count">{String(submitCount)}</output>
|
||||
</form.AppForm>
|
||||
)
|
||||
}
|
||||
|
||||
describe("translateModelSelector", () => {
|
||||
it("keeps invalid form values while staging recommended provider options", async () => {
|
||||
render(<TranslateModelSelectorHarness />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText("provider-name"), {
|
||||
target: { value: duplicateProviderName },
|
||||
})
|
||||
await flushUpdates()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "apply-recommendation" }))
|
||||
await flushUpdates()
|
||||
|
||||
expect(screen.getByLabelText("provider-name")).toHaveValue(duplicateProviderName)
|
||||
expect(screen.getByLabelText("form-name")).toHaveTextContent(duplicateProviderName)
|
||||
expect(screen.getByLabelText("persisted-name")).toHaveTextContent("OpenAI")
|
||||
expect(screen.getByLabelText("form-provider-options")).toHaveTextContent("{\"reasoningEffort\":\"minimal\"}")
|
||||
expect(screen.getByLabelText("current-provider-options-prop")).toHaveTextContent("{\"reasoningEffort\":\"minimal\"}")
|
||||
expect(screen.getByLabelText("persisted-provider-options")).toHaveTextContent("null")
|
||||
expect(screen.getByLabelText("submit-count")).toHaveTextContent("0")
|
||||
})
|
||||
|
||||
it("persists staged recommendations after the validation error is fixed", async () => {
|
||||
render(<TranslateModelSelectorHarness />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText("provider-name"), {
|
||||
target: { value: duplicateProviderName },
|
||||
})
|
||||
await flushUpdates()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "apply-recommendation" }))
|
||||
await flushUpdates()
|
||||
|
||||
fireEvent.change(screen.getByLabelText("provider-name"), {
|
||||
target: { value: "OpenAI Saved" },
|
||||
})
|
||||
await flushUpdates()
|
||||
|
||||
expect(screen.getByLabelText("persisted-name")).toHaveTextContent("OpenAI Saved")
|
||||
expect(screen.getByLabelText("persisted-provider-options")).toHaveTextContent("{\"reasoningEffort\":\"minimal\"}")
|
||||
expect(screen.getByLabelText("submit-count")).toHaveTextContent("1")
|
||||
})
|
||||
|
||||
it("submits recommendations immediately when the form is valid", async () => {
|
||||
render(<TranslateModelSelectorHarness />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "apply-recommendation" }))
|
||||
await flushUpdates()
|
||||
|
||||
expect(screen.getByLabelText("persisted-name")).toHaveTextContent("OpenAI")
|
||||
expect(screen.getByLabelText("persisted-provider-options")).toHaveTextContent("{\"reasoningEffort\":\"minimal\"}")
|
||||
expect(screen.getByLabelText("submit-count")).toHaveTextContent("1")
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
// @vitest-environment jsdom
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { ProviderOptionsRecommendationTrigger } from "../provider-options-recommendation-trigger"
|
||||
|
||||
vi.mock("#imports", () => ({
|
||||
i18n: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui/json-code-editor", () => ({
|
||||
JSONCodeEditor: ({
|
||||
value,
|
||||
placeholder,
|
||||
}: {
|
||||
value?: string
|
||||
placeholder?: string
|
||||
}) => (
|
||||
<pre data-testid="provider-options-preview">
|
||||
{value || placeholder}
|
||||
</pre>
|
||||
),
|
||||
}))
|
||||
|
||||
describe("providerOptionsRecommendationTrigger", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("does not render a trigger when the current model has no recommendation", () => {
|
||||
render(
|
||||
<ProviderOptionsRecommendationTrigger
|
||||
providerId="provider-1"
|
||||
modelId="plain-model"
|
||||
onApply={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole("button", {
|
||||
name: "options.apiProviders.form.providerOptionsRecommendationTrigger",
|
||||
})).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("flashes once when the model starts matching a new recommendation rule", () => {
|
||||
const { rerender } = render(
|
||||
<ProviderOptionsRecommendationTrigger
|
||||
providerId="provider-1"
|
||||
modelId="gpt-5-mini"
|
||||
onApply={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole("button", {
|
||||
name: "options.apiProviders.form.providerOptionsRecommendationTrigger",
|
||||
})
|
||||
expect(trigger.className).not.toContain("text-primary")
|
||||
|
||||
rerender(
|
||||
<ProviderOptionsRecommendationTrigger
|
||||
providerId="provider-1"
|
||||
modelId="gpt-5.1"
|
||||
onApply={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(trigger.className).toContain("text-primary")
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1400)
|
||||
})
|
||||
|
||||
expect(trigger.className).not.toContain("text-primary")
|
||||
})
|
||||
|
||||
it("shows the recommendation preview and applies it on demand", () => {
|
||||
const onApply = vi.fn()
|
||||
|
||||
render(
|
||||
<ProviderOptionsRecommendationTrigger
|
||||
providerId="provider-1"
|
||||
modelId="gpt-5-mini"
|
||||
onApply={onApply}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", {
|
||||
name: "options.apiProviders.form.providerOptionsRecommendationTrigger",
|
||||
}))
|
||||
|
||||
expect(screen.getByText("options.apiProviders.form.providerOptionsRecommendationTitle")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("provider-options-preview")).toHaveTextContent("\"reasoningEffort\": \"minimal\"")
|
||||
|
||||
fireEvent.click(screen.getByRole("button", {
|
||||
name: "options.apiProviders.form.providerOptionsRecommendationApply",
|
||||
}))
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith({ reasoningEffort: "minimal" })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import type { JSONValue } from "ai"
|
||||
import { i18n } from "#imports"
|
||||
import { Icon } from "@iconify/react"
|
||||
import { dequal } from "dequal"
|
||||
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"
|
||||
import { Button } from "@/components/ui/base-ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/base-ui/popover"
|
||||
import { JSONCodeEditor } from "@/components/ui/json-code-editor"
|
||||
import { getRecommendedProviderOptionsMatch } from "@/utils/providers/options"
|
||||
import { cn } from "@/utils/styles/utils"
|
||||
|
||||
interface ProviderOptionsRecommendationTriggerProps {
|
||||
providerId: string
|
||||
modelId?: string | null
|
||||
currentProviderOptions?: Record<string, JSONValue>
|
||||
onApply: (options: Record<string, JSONValue>) => void
|
||||
}
|
||||
|
||||
const FLASH_DURATION_MS = 1400
|
||||
|
||||
export function ProviderOptionsRecommendationTrigger({
|
||||
providerId,
|
||||
modelId,
|
||||
currentProviderOptions,
|
||||
onApply,
|
||||
}: ProviderOptionsRecommendationTriggerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isFlashing, setIsFlashing] = useState(false)
|
||||
const hasMountedRef = useRef(false)
|
||||
const previousProviderIdRef = useRef(providerId)
|
||||
const previousMatchIndexRef = useRef<number | undefined>(undefined)
|
||||
|
||||
const closePopover = useEffectEvent(() => {
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setOpen(false)
|
||||
})
|
||||
|
||||
const startFlashing = useEffectEvent(() => {
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setIsFlashing(true)
|
||||
})
|
||||
|
||||
const stopFlashing = useEffectEvent(() => {
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setIsFlashing(false)
|
||||
})
|
||||
|
||||
const recommendation = useMemo(() => {
|
||||
if (!modelId?.trim()) {
|
||||
return undefined
|
||||
}
|
||||
return getRecommendedProviderOptionsMatch(modelId.trim())
|
||||
}, [modelId])
|
||||
|
||||
const recommendationJson = useMemo(() => {
|
||||
if (!recommendation) {
|
||||
return ""
|
||||
}
|
||||
return JSON.stringify(recommendation.options, null, 2)
|
||||
}, [recommendation])
|
||||
|
||||
const isApplied = useMemo(() => {
|
||||
if (!recommendation || !currentProviderOptions) {
|
||||
return false
|
||||
}
|
||||
return dequal(currentProviderOptions, recommendation.options)
|
||||
}, [currentProviderOptions, recommendation])
|
||||
|
||||
useEffect(() => {
|
||||
if (!recommendation) {
|
||||
closePopover()
|
||||
}
|
||||
}, [recommendation])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMountedRef.current) {
|
||||
hasMountedRef.current = true
|
||||
previousProviderIdRef.current = providerId
|
||||
previousMatchIndexRef.current = recommendation?.matchIndex
|
||||
return
|
||||
}
|
||||
|
||||
if (previousProviderIdRef.current !== providerId) {
|
||||
previousProviderIdRef.current = providerId
|
||||
previousMatchIndexRef.current = recommendation?.matchIndex
|
||||
stopFlashing()
|
||||
return
|
||||
}
|
||||
|
||||
if (previousMatchIndexRef.current !== recommendation?.matchIndex) {
|
||||
previousMatchIndexRef.current = recommendation?.matchIndex
|
||||
|
||||
if (recommendation) {
|
||||
startFlashing()
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIsFlashing(false)
|
||||
}, FLASH_DURATION_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
stopFlashing()
|
||||
}
|
||||
}, [providerId, recommendation])
|
||||
|
||||
if (!recommendation) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(recommendation.options)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={i18n.t("options.apiProviders.form.providerOptionsRecommendationTrigger")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground transition-colors duration-700 ease-in-out",
|
||||
isFlashing && "text-primary",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Icon icon="tabler:sparkles" className="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 gap-3">
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>{i18n.t("options.apiProviders.form.providerOptionsRecommendationTitle")}</PopoverTitle>
|
||||
<PopoverDescription>{i18n.t("options.apiProviders.form.providerOptionsRecommendationDescription")}</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<JSONCodeEditor
|
||||
value={recommendationJson}
|
||||
editable={false}
|
||||
height="132px"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isApplied ? "secondary" : "default"}
|
||||
disabled={isApplied}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{isApplied
|
||||
? i18n.t("options.apiProviders.form.providerOptionsRecommendationApplied")
|
||||
: i18n.t("options.apiProviders.form.providerOptionsRecommendationApply")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
import type { APIProviderConfig, LLMProviderConfig } from "@/types/config/provider"
|
||||
import type { APIProviderConfig } from "@/types/config/provider"
|
||||
import { i18n } from "#imports"
|
||||
import { useStore } from "@tanstack/react-form"
|
||||
import { useEffect, useEffectEvent, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"
|
||||
import { HelpTooltip } from "@/components/help-tooltip"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/base-ui/field"
|
||||
import { JSONCodeEditor } from "@/components/ui/json-code-editor"
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value"
|
||||
import { isLLMProviderConfig } from "@/types/config/provider"
|
||||
import { resolveModelId } from "@/utils/providers/model"
|
||||
import { getProviderOptions } from "@/utils/providers/options"
|
||||
import { withForm } from "./form"
|
||||
|
||||
function parseJson(input: string): { valid: true, value: Record<string, unknown> | undefined } | { valid: false, error: string } {
|
||||
|
|
@ -29,22 +27,51 @@ export const ProviderOptionsField = withForm({
|
|||
const providerConfig = useStore(form.store, state => state.values)
|
||||
const isLLMProvider = isLLMProviderConfig(providerConfig)
|
||||
|
||||
const toJson = (options: APIProviderConfig["providerOptions"]) =>
|
||||
options ? JSON.stringify(options, null, 2) : ""
|
||||
const toJson = useCallback(
|
||||
(options: APIProviderConfig["providerOptions"]) => options ? JSON.stringify(options, null, 2) : "",
|
||||
[],
|
||||
)
|
||||
const placeholderText = JSON.stringify({ field: "value" }, null, 2)
|
||||
const externalJson = toJson(providerConfig.providerOptions)
|
||||
|
||||
// Local state for the JSON string input
|
||||
const [jsonInput, setJsonInput] = useState(() => toJson(providerConfig.providerOptions))
|
||||
const [jsonInput, setJsonInput] = useState(() => externalJson)
|
||||
const lastCommittedJsonRef = useRef(externalJson)
|
||||
const pendingEditorCommitRef = useRef(false)
|
||||
|
||||
// Keep editor input in sync when switching to a different provider config.
|
||||
const syncJsonInput = useEffectEvent(() => {
|
||||
const syncJsonInput = useEffectEvent((nextJson: string) => {
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setJsonInput(toJson(providerConfig.providerOptions))
|
||||
setJsonInput(nextJson)
|
||||
})
|
||||
|
||||
const resetSyncStateForProvider = useEffectEvent(() => {
|
||||
lastCommittedJsonRef.current = externalJson
|
||||
pendingEditorCommitRef.current = false
|
||||
syncJsonInput(externalJson)
|
||||
})
|
||||
|
||||
const readJsonInput = useEffectEvent(() => {
|
||||
return jsonInput
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
syncJsonInput()
|
||||
resetSyncStateForProvider()
|
||||
}, [providerConfig.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingEditorCommitRef.current && externalJson === lastCommittedJsonRef.current) {
|
||||
pendingEditorCommitRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
pendingEditorCommitRef.current = false
|
||||
lastCommittedJsonRef.current = externalJson
|
||||
|
||||
if (readJsonInput() !== externalJson) {
|
||||
syncJsonInput(externalJson)
|
||||
}
|
||||
}, [providerConfig.providerOptions, externalJson])
|
||||
|
||||
// Debounce the input value
|
||||
const debouncedJsonInput = useDebouncedValue(jsonInput, 500)
|
||||
|
||||
|
|
@ -54,33 +81,17 @@ export const ProviderOptionsField = withForm({
|
|||
// Submit when debounced value changes and is valid
|
||||
useEffect(() => {
|
||||
if (parseResult.valid) {
|
||||
const normalizedJson = toJson(parseResult.value)
|
||||
if (normalizedJson === lastCommittedJsonRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCommittedJsonRef.current = normalizedJson
|
||||
pendingEditorCommitRef.current = true
|
||||
form.setFieldValue("providerOptions", parseResult.value)
|
||||
void form.handleSubmit()
|
||||
}
|
||||
}, [parseResult, form])
|
||||
|
||||
const translateModel = useMemo(() => {
|
||||
if (!isLLMProvider) {
|
||||
return null
|
||||
}
|
||||
const llmConfig = providerConfig as LLMProviderConfig
|
||||
return resolveModelId(llmConfig.model)
|
||||
}, [isLLMProvider, providerConfig])
|
||||
|
||||
const defaultOptions = useMemo(() => {
|
||||
if (!isLLMProvider || !translateModel) {
|
||||
return {}
|
||||
}
|
||||
const options = getProviderOptions(translateModel, providerConfig.provider)
|
||||
return options[providerConfig.provider] || {}
|
||||
}, [isLLMProvider, translateModel, providerConfig.provider])
|
||||
|
||||
const placeholderText = useMemo(() => {
|
||||
if (Object.keys(defaultOptions).length === 0) {
|
||||
return "{\n \n}"
|
||||
}
|
||||
return JSON.stringify(defaultOptions, null, 2)
|
||||
}, [defaultOptions])
|
||||
}, [parseResult, form, toJson])
|
||||
|
||||
if (!isLLMProvider) {
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { Checkbox } from "@/components/ui/base-ui/checkbox"
|
|||
import { SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/base-ui/select"
|
||||
import { isCustomLLMProviderConfig, isLLMProviderConfig, LLM_PROVIDER_MODELS } from "@/types/config/provider"
|
||||
import { providerConfigAtom, updateLLMProviderConfig } from "@/utils/atoms/provider"
|
||||
import { resolveModelId } from "@/utils/providers/model"
|
||||
import { ModelSuggestionButton } from "./components/model-suggestion-button"
|
||||
import { ProviderOptionsRecommendationTrigger } from "./components/provider-options-recommendation-trigger"
|
||||
import { withForm } from "./form"
|
||||
|
||||
export const TranslateModelSelector = withForm({
|
||||
|
|
@ -18,8 +20,23 @@ export const TranslateModelSelector = withForm({
|
|||
if (!isLLMProviderConfig(providerConfig))
|
||||
return <></>
|
||||
|
||||
const modelId = resolveModelId(providerConfig.model)
|
||||
const { isCustomModel, customModel, model } = providerConfig.model
|
||||
|
||||
const applyRecommendedProviderOptions = (options: Record<string, unknown>) => {
|
||||
form.setFieldValue("providerOptions", options)
|
||||
void form.handleSubmit()
|
||||
}
|
||||
|
||||
const recommendationTrigger = (
|
||||
<ProviderOptionsRecommendationTrigger
|
||||
providerId={providerConfig.id}
|
||||
modelId={modelId}
|
||||
currentProviderOptions={providerConfig.providerOptions}
|
||||
onApply={applyRecommendedProviderOptions}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isCustomModel
|
||||
|
|
@ -29,15 +46,20 @@ export const TranslateModelSelector = withForm({
|
|||
<field.InputFieldAutoSave
|
||||
formForSubmit={form}
|
||||
label={i18n.t("options.general.translationConfig.model.title")}
|
||||
labelExtra={isCustomLLMProviderConfig(providerConfig) && (
|
||||
<ModelSuggestionButton
|
||||
baseURL={providerConfig.baseURL}
|
||||
apiKey={providerConfig.apiKey}
|
||||
onSelect={(model) => {
|
||||
field.handleChange(model)
|
||||
void form.handleSubmit()
|
||||
}}
|
||||
/>
|
||||
labelExtra={(
|
||||
<div className="flex items-center gap-2">
|
||||
{recommendationTrigger}
|
||||
{isCustomLLMProviderConfig(providerConfig) && (
|
||||
<ModelSuggestionButton
|
||||
baseURL={providerConfig.baseURL}
|
||||
apiKey={providerConfig.apiKey}
|
||||
onSelect={(model) => {
|
||||
field.handleChange(model)
|
||||
void form.handleSubmit()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
value={customModel ?? ""}
|
||||
/>
|
||||
|
|
@ -47,7 +69,11 @@ export const TranslateModelSelector = withForm({
|
|||
: (
|
||||
<form.AppField name="model.model">
|
||||
{field => (
|
||||
<field.SelectFieldAutoSave formForSubmit={form} label={i18n.t("options.general.translationConfig.model.title")}>
|
||||
<field.SelectFieldAutoSave
|
||||
formForSubmit={form}
|
||||
label={i18n.t("options.general.translationConfig.model.title")}
|
||||
labelExtra={recommendationTrigger}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={i18n.t("options.apiProviders.form.models.translate.placeholder")} />
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -135,6 +135,11 @@ options:
|
|||
providerOptions: Provider Options
|
||||
providerOptionsHint: Provider-specific options for non-standard API features like thinking/reasoning mode. See the Vercel AI SDK docs or your provider's official documentation for configuration details.
|
||||
providerOptionsDocsLink: View Provider Docs
|
||||
providerOptionsRecommendationTrigger: View recommended provider options
|
||||
providerOptionsRecommendationTitle: Recommended provider options detected
|
||||
providerOptionsRecommendationDescription: Review and manually apply the recommended configuration.
|
||||
providerOptionsRecommendationApply: Apply
|
||||
providerOptionsRecommendationApplied: Applied
|
||||
invalidJson: Invalid JSON format
|
||||
providers:
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -134,6 +134,11 @@ options:
|
|||
providerOptions: プロバイダーオプション
|
||||
providerOptionsHint: 思考/推論モードなどの非標準API機能のためのプロバイダー固有オプション。詳細はVercel AI SDKドキュメントまたはプロバイダーの公式ドキュメントをご覧ください。
|
||||
providerOptionsDocsLink: プロバイダードキュメントを見る
|
||||
providerOptionsRecommendationTrigger: 推奨プロバイダーオプションを表示
|
||||
providerOptionsRecommendationTitle: 推奨プロバイダーオプションが見つかりました
|
||||
providerOptionsRecommendationDescription: 推奨設定を確認し、手動で適用してください。
|
||||
providerOptionsRecommendationApply: 適用
|
||||
providerOptionsRecommendationApplied: 適用済み
|
||||
invalidJson: 無効なJSON形式
|
||||
providers:
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -134,6 +134,11 @@ options:
|
|||
providerOptions: 제공자 옵션
|
||||
providerOptionsHint: 사고/추론 모드와 같은 비표준 API 기능을 위한 공급자별 옵션입니다. 자세한 내용은 Vercel AI SDK 문서 또는 공급자의 공식 문서를 참조하세요.
|
||||
providerOptionsDocsLink: 공급자 문서 보기
|
||||
providerOptionsRecommendationTrigger: 추천 제공자 옵션 보기
|
||||
providerOptionsRecommendationTitle: 추천 제공자 옵션이 감지되었습니다
|
||||
providerOptionsRecommendationDescription: 추천 구성을 검토한 뒤 수동으로 적용하세요.
|
||||
providerOptionsRecommendationApply: 적용
|
||||
providerOptionsRecommendationApplied: 적용됨
|
||||
invalidJson: 잘못된 JSON 형식
|
||||
providers:
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -134,6 +134,11 @@ options:
|
|||
providerOptions: Параметры провайдера
|
||||
providerOptionsHint: Специфические параметры провайдера для нестандартных функций API, таких как режим размышления/рассуждения. Подробнее см. документацию Vercel AI SDK или официальную документацию вашего провайдера.
|
||||
providerOptionsDocsLink: Документация провайдера
|
||||
providerOptionsRecommendationTrigger: Показать рекомендуемые параметры провайдера
|
||||
providerOptionsRecommendationTitle: Обнаружены рекомендуемые параметры провайдера
|
||||
providerOptionsRecommendationDescription: Проверьте рекомендованную конфигурацию и примените её вручную.
|
||||
providerOptionsRecommendationApply: Применить
|
||||
providerOptionsRecommendationApplied: Применено
|
||||
invalidJson: Неверный формат JSON
|
||||
providers:
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -134,6 +134,11 @@ options:
|
|||
providerOptions: Sağlayıcı Seçenekleri
|
||||
providerOptionsHint: Düşünme/akıl yürütme modu gibi standart dışı API özellikleri için sağlayıcıya özel seçenekler. Ayrıntılar için Vercel AI SDK belgelerine veya sağlayıcınızın resmi belgelerine bakın.
|
||||
providerOptionsDocsLink: Sağlayıcı Belgelerini Görüntüle
|
||||
providerOptionsRecommendationTrigger: Önerilen sağlayıcı seçeneklerini görüntüle
|
||||
providerOptionsRecommendationTitle: Önerilen sağlayıcı seçenekleri bulundu
|
||||
providerOptionsRecommendationDescription: Önerilen yapılandırmayı gözden geçirip manuel olarak uygulayın.
|
||||
providerOptionsRecommendationApply: Uygula
|
||||
providerOptionsRecommendationApplied: Uygulandı
|
||||
invalidJson: Geçersiz JSON formatı
|
||||
providers:
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -134,6 +134,11 @@ options:
|
|||
providerOptions: Tùy chọn nhà cung cấp
|
||||
providerOptionsHint: Các tùy chọn dành riêng cho nhà cung cấp cho các tính năng API không chuẩn như chế độ suy nghĩ/lập luận. Xem tài liệu Vercel AI SDK hoặc tài liệu chính thức của nhà cung cấp để biết chi tiết cấu hình.
|
||||
providerOptionsDocsLink: Xem Tài liệu Nhà cung cấp
|
||||
providerOptionsRecommendationTrigger: Xem tùy chọn nhà cung cấp được đề xuất
|
||||
providerOptionsRecommendationTitle: Đã phát hiện tùy chọn nhà cung cấp được đề xuất
|
||||
providerOptionsRecommendationDescription: Hãy xem lại và tự áp dụng cấu hình được đề xuất.
|
||||
providerOptionsRecommendationApply: Áp dụng
|
||||
providerOptionsRecommendationApplied: Đã áp dụng
|
||||
invalidJson: Định dạng JSON không hợp lệ
|
||||
providers:
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -135,6 +135,11 @@ options:
|
|||
providerOptions: 提供商选项
|
||||
providerOptionsHint: 用于非标准 API 功能(如思考/推理模式)的提供商特定选项。详情请参阅 Vercel AI SDK 文档或您的提供商官方文档。
|
||||
providerOptionsDocsLink: 查看提供商文档
|
||||
providerOptionsRecommendationTrigger: 查看推荐的提供商选项
|
||||
providerOptionsRecommendationTitle: 检测到推荐的提供商选项
|
||||
providerOptionsRecommendationDescription: 请先检查,再手动应用推荐配置。
|
||||
providerOptionsRecommendationApply: 应用
|
||||
providerOptionsRecommendationApplied: 已应用
|
||||
invalidJson: JSON 格式无效
|
||||
providers:
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -134,6 +134,11 @@ options:
|
|||
providerOptions: 提供者選項
|
||||
providerOptionsHint: 用於非標準 API 功能(如思考/推理模式)的提供者特定選項。詳情請參閱 Vercel AI SDK 文件或您的提供者官方文件。
|
||||
providerOptionsDocsLink: 查看提供者文件
|
||||
providerOptionsRecommendationTrigger: 查看推薦的提供者選項
|
||||
providerOptionsRecommendationTitle: 偵測到推薦的提供者選項
|
||||
providerOptionsRecommendationDescription: 請先檢查,再手動套用推薦設定。
|
||||
providerOptionsRecommendationApply: 套用
|
||||
providerOptionsRecommendationApplied: 已套用
|
||||
invalidJson: JSON 格式無效
|
||||
providers:
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { getProviderOptions } from "../../providers/options"
|
||||
import {
|
||||
getProviderOptions,
|
||||
getProviderOptionsWithOverride,
|
||||
getRecommendedProviderOptionsMatch,
|
||||
} from "../../providers/options"
|
||||
|
||||
describe("getProviderOptions", () => {
|
||||
describe("model pattern matching", () => {
|
||||
|
|
@ -101,4 +105,36 @@ describe("getProviderOptions", () => {
|
|||
expect(end.openaiCompatible).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("user provider option overrides", () => {
|
||||
it("should treat an explicit empty object as cleared provider options", () => {
|
||||
const options = getProviderOptionsWithOverride("qwen3-max", "alibaba", {})
|
||||
expect(options).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should not fall back to recommendations when user options are undefined", () => {
|
||||
const options = getProviderOptionsWithOverride("qwen3-max", "alibaba")
|
||||
expect(options).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should use user options as-is without merging matched defaults", () => {
|
||||
const options = getProviderOptionsWithOverride("qwen3-max", "alibaba", { foo: "bar" })
|
||||
expect(options).toEqual({ alibaba: { foo: "bar" } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("recommendation metadata", () => {
|
||||
it("should expose the matched rule index for UI suggestion state", () => {
|
||||
const gpt5Match = getRecommendedProviderOptionsMatch("gpt-5-mini")
|
||||
const gpt51Match = getRecommendedProviderOptionsMatch("gpt-5.1")
|
||||
|
||||
expect(gpt5Match?.matchIndex).toBeTypeOf("number")
|
||||
expect(gpt51Match?.matchIndex).toBeTypeOf("number")
|
||||
expect(gpt5Match?.matchIndex).not.toBe(gpt51Match?.matchIndex)
|
||||
})
|
||||
|
||||
it("should return undefined for models without recommendations", () => {
|
||||
expect(getRecommendedProviderOptionsMatch("plain-model")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export const LLM_MODEL_OPTIONS: Array<{
|
|||
|
||||
// Qwen/QwQ models - disable thinking
|
||||
{
|
||||
pattern: /^qwen/,
|
||||
pattern: /^(Qwen|qwen)/,
|
||||
options: { enableThinking: false },
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,37 +1,57 @@
|
|||
import type { JSONValue } from "ai"
|
||||
import { LLM_MODEL_OPTIONS } from "../constants/models"
|
||||
|
||||
export interface RecommendedProviderOptionsMatch {
|
||||
matchIndex: number
|
||||
options: Record<string, JSONValue>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider options for AI SDK generateText calls.
|
||||
* Matches model name against patterns and returns options for the current provider.
|
||||
* Detect the recommended provider options for a given model.
|
||||
* First match wins - more specific patterns should be placed first in MODEL_OPTIONS.
|
||||
*/
|
||||
export function getRecommendedProviderOptionsMatch(model: string): RecommendedProviderOptionsMatch | undefined {
|
||||
for (const [matchIndex, { pattern, options }] of LLM_MODEL_OPTIONS.entries()) {
|
||||
if (pattern.test(model)) {
|
||||
return { matchIndex, options }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recommended provider options payload without wrapping it by provider id.
|
||||
*/
|
||||
export function getRecommendedProviderOptions(model: string): Record<string, JSONValue> | undefined {
|
||||
return getRecommendedProviderOptionsMatch(model)?.options
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a recommendation for the AI SDK request shape.
|
||||
*/
|
||||
export function getProviderOptions(
|
||||
model: string,
|
||||
provider: string,
|
||||
): Record<string, Record<string, JSONValue>> {
|
||||
for (const { pattern, options } of LLM_MODEL_OPTIONS) {
|
||||
if (pattern.test(model)) {
|
||||
return { [provider]: options }
|
||||
}
|
||||
const options = getRecommendedProviderOptions(model)
|
||||
if (!options) {
|
||||
return {}
|
||||
}
|
||||
return {}
|
||||
|
||||
return { [provider]: options }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider options for AI SDK generateText calls.
|
||||
* If user-defined options exist, use them directly (no merge).
|
||||
* Otherwise fall back to default pattern-matched options.
|
||||
* Get provider options for AI SDK generateText calls using only user-saved overrides.
|
||||
* Recommendations stay in the UI until the user explicitly applies and saves them.
|
||||
*/
|
||||
export function getProviderOptionsWithOverride(
|
||||
model: string,
|
||||
_model: string,
|
||||
provider: string,
|
||||
userOptions?: Record<string, JSONValue>,
|
||||
): Record<string, Record<string, JSONValue>> {
|
||||
// User options completely override defaults
|
||||
if (userOptions && Object.keys(userOptions).length > 0) {
|
||||
return { [provider]: userOptions }
|
||||
): Record<string, Record<string, JSONValue>> | undefined {
|
||||
if (!userOptions || Object.keys(userOptions).length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return getProviderOptions(model, provider)
|
||||
return { [provider]: userOptions }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue