فهرست منبع

model variants selection

paviko 2 ماه پیش
والد
کامیت
eb6c2e2a2c

+ 2 - 0
packages/opencode/src/webgui/server/webgui.ts

@@ -421,6 +421,7 @@ export const WebGuiRoute = new Hono()
                     provider: z.string().optional(),
                     model: z.string().optional(),
                     agent: z.string().optional(),
+                    variant: z.record(z.string(), z.string()).optional(),
                     recently_used_models: z
                       .array(
                         z.object({
@@ -479,6 +480,7 @@ export const WebGuiRoute = new Hono()
                     provider: z.string().optional(),
                     model: z.string().optional(),
                     agent: z.string().optional(),
+                    variant: z.record(z.string(), z.string()).optional(),
                     recently_used_models: z
                       .array(
                         z.object({

+ 6 - 0
packages/opencode/src/webgui/state/state.ts

@@ -30,6 +30,7 @@ export const StateSchema = z.object({
   provider: z.string().optional(),
   model: z.string().optional(),
   agent: z.string().optional(),
+  variant: z.record(z.string(), z.string()).optional(),
   recently_used_models: z.array(ModelUsageSchema).optional(),
   recently_used_agents: z.array(AgentUsageSchema).optional(),
   show_tool_details: z.boolean().optional(),
@@ -144,6 +145,11 @@ export async function write(partial: Partial<State>): Promise<void> {
       ;(raw as any).agent_model = { ...existingAgentModel, ...partial.agent_model }
     }
 
+    if (partial.variant) {
+      const existingVariant = ((raw as any).variant as Record<string, string> | undefined) || {}
+      ;(raw as any).variant = { ...existingVariant, ...partial.variant }
+    }
+
     const toml = TOML.stringify(raw as any)
     await Bun.write(STATE_FILE_PATH, toml)
   } catch (error) {

+ 16 - 0
packages/opencode/webgui/src/components/MessageInput/EditorToolbar.tsx

@@ -1,5 +1,6 @@
 import { ModelSelector } from "../ModelSelector"
 import { AgentSelector } from "../AgentSelector"
+import { VariantSelector } from "../VariantSelector"
 import { IconButton } from "../common"
 import { MessageActions } from "./MessageActions"
 
@@ -22,6 +23,10 @@ interface EditorToolbarProps {
   onSubmit: () => void
   onAbort: () => void
   onCompactClick: () => void
+  variants?: string[]
+  selectedVariant?: string
+  onVariantSelect: (variant: string | undefined) => void
+  isReasoningModel?: boolean
 }
 
 export function EditorToolbar({
@@ -43,6 +48,10 @@ export function EditorToolbar({
   onSubmit,
   onAbort,
   onCompactClick,
+  variants,
+  selectedVariant,
+  onVariantSelect,
+  isReasoningModel,
 }: EditorToolbarProps) {
   return (
     <div className="h-8 px-2 flex items-center justify-between border-t border-gray-100 dark:border-gray-800">
@@ -71,6 +80,13 @@ export function EditorToolbar({
           onSelect={onModelSelect}
           disabled={isDisabled}
         />
+        <VariantSelector
+          variants={variants}
+          selectedVariant={selectedVariant}
+          onSelect={onVariantSelect}
+          disabled={isDisabled}
+          isReasoningModel={isReasoningModel}
+        />
         <AgentSelector selectedAgent={selectedAgent} onSelect={onAgentSelect} disabled={isDisabled} />
         <IconButton
           onClick={onFileSelect}

+ 8 - 0
packages/opencode/webgui/src/components/MessageInput/hooks/useMessageInput.ts

@@ -11,6 +11,7 @@ interface UseMessageInputOptions {
   selectedProviderId: string | undefined
   selectedModelId: string | undefined
   selectedAgent: string
+  selectedVariant: string | undefined
   extractMessageParts: () => any[]
   onMessageSent?: () => void
   onError?: (error: Error) => void
@@ -23,6 +24,7 @@ export function useMessageInput({
   selectedProviderId,
   selectedModelId,
   selectedAgent,
+  selectedVariant,
   extractMessageParts,
   onMessageSent,
   onError,
@@ -86,6 +88,11 @@ export function useMessageInput({
       // Always include agent (defaults to 'build')
       requestBody.agent = selectedAgent
 
+      // Add variant if selected
+      if (selectedVariant) {
+        requestBody.variant = selectedVariant
+      }
+
       // Clear editor immediately (optimistic UI)
       editor.update(() => {
         const root = $getRoot()
@@ -141,6 +148,7 @@ export function useMessageInput({
     selectedProviderId,
     selectedModelId,
     selectedAgent,
+    selectedVariant,
     onMessageSent,
     onError,
     setIsIdle,

+ 74 - 3
packages/opencode/webgui/src/components/MessageInput/index.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from "react"
+import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle, useMemo } from "react"
 import { LexicalComposer } from "@lexical/react/LexicalComposer"
 import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
 import { $getRoot, $getSelection, $isRangeSelection, $createTextNode, type EditorState } from "lexical"
@@ -6,6 +6,8 @@ import { $createMentionNode } from "../mention/MentionNode"
 import { useSession } from "../../state/SessionContext"
 import { useProject } from "../../state/ProjectContext"
 import { useProviders } from "../../state/ProvidersContext"
+import { sdk } from "../../lib/api/sdkClient"
+import type { Provider } from "@opencode-ai/sdk/client"
 import { toProjectRelative } from "../../utils/path"
 import { ConfirmModal } from "../ConfirmModal"
 import { createEditorConfig } from "./EditorConfig"
@@ -62,10 +64,21 @@ const MessageInputInner = forwardRef<
   const contentEditableRef = useRef<HTMLDivElement>(null)
   const containerRef = useRef<HTMLDivElement>(null)
   const { worktree } = useProject()
-  const { isIdle, selectedProviderId, selectedModelId, selectedAgent, setSelectedModel, setSelectedAgent } =
-    useSession()
+  const {
+    isIdle,
+    selectedProviderId,
+    selectedModelId,
+    selectedAgent,
+    setSelectedModel,
+    setSelectedAgent,
+    selectedVariant,
+    setSelectedVariant,
+  } = useSession()
   const { providersDirty, clearProvidersDirty } = useProviders()
 
+  // Providers state for variants computation
+  const [providers, setProviders] = useState<Provider[]>([])
+
   const handleEditorChange = useCallback((editorState: EditorState) => {
     editorState.read(() => {
       const root = $getRoot()
@@ -119,6 +132,7 @@ const MessageInputInner = forwardRef<
     isEmpty,
     selectedProviderId,
     selectedModelId,
+    selectedVariant,
     selectedAgent,
     extractMessageParts,
     onMessageSent,
@@ -222,13 +236,66 @@ const MessageInputInner = forwardRef<
     editor.setEditable(!isSending)
   }, [editor, isSending])
 
+  // Load providers for variant computation
+  useEffect(() => {
+    let active = true
+    async function loadProviders() {
+      try {
+        const response = await sdk.config.providers()
+        if (!active) return
+        if (response.data) {
+          setProviders(response.data.providers)
+        }
+      } catch (err) {
+        console.error("[MessageInput] Failed to load providers:", err)
+      }
+    }
+    loadProviders()
+    return () => {
+      active = false
+    }
+  }, [])
+
   // Update model selector when providers change
   useEffect(() => {
     if (!providersDirty) return
     setModelSelectorKey((value) => value + 1)
+    // Reload providers when dirty
+    sdk.config.providers().then((response) => {
+      if (response.data) {
+        setProviders(response.data.providers)
+      }
+    })
     clearProvidersDirty()
   }, [providersDirty, clearProvidersDirty])
 
+  const currentModelInfo = useMemo(() => {
+    if (!selectedProviderId || !selectedModelId) {
+      return {
+        variants: undefined as string[] | undefined,
+        isReasoning: false,
+      }
+    }
+    const provider = providers.find((p) => p.id === selectedProviderId)
+    if (!provider) {
+      return {
+        variants: undefined,
+        isReasoning: false,
+      }
+    }
+    const model = provider.models?.[selectedModelId] as
+      | ((typeof provider.models)[string] & {
+          variants?: Record<string, unknown>
+          capabilities?: { reasoning?: boolean }
+        })
+      | undefined
+
+    return {
+      variants: model?.variants ? Object.keys(model.variants) : undefined,
+      isReasoning: !!model?.capabilities?.reasoning,
+    }
+  }, [providers, selectedProviderId, selectedModelId])
+
   const isDisabled = isSending
   const isButtonDisabled = isDisabled || isEmpty
   const isCompactDisabled =
@@ -275,6 +342,10 @@ const MessageInputInner = forwardRef<
           onSubmit={handleSubmit}
           onAbort={handleAbort}
           onCompactClick={() => setIsCompactConfirmOpen(true)}
+          variants={currentModelInfo.variants}
+          selectedVariant={selectedVariant}
+          onVariantSelect={(variant) => setSelectedVariant(variant)}
+          isReasoningModel={currentModelInfo.isReasoning}
         />
       </footer>
 

+ 118 - 0
packages/opencode/webgui/src/components/VariantSelector.tsx

@@ -0,0 +1,118 @@
+import { useDropdown } from "../hooks/useDropdown"
+
+interface VariantSelectorProps {
+  variants: string[] | undefined
+  selectedVariant: string | undefined
+  onSelect: (variant: string | undefined) => void
+  disabled?: boolean
+  isReasoningModel?: boolean
+}
+
+const formatVariantName = (variant: string) => {
+  return variant.charAt(0).toUpperCase() + variant.slice(1)
+}
+
+export function VariantSelector({
+  variants,
+  selectedVariant,
+  onSelect,
+  disabled,
+  isReasoningModel,
+}: VariantSelectorProps) {
+  const { isOpen, dropdownRef, close, toggle } = useDropdown()
+
+  const hasVariants = variants && variants.length > 0
+  const isDisabled = disabled || !hasVariants
+
+  const handleSelect = (variant: string | undefined) => {
+    onSelect(variant)
+    close()
+  }
+
+  const getCurrentDisplay = () => {
+    if (selectedVariant) return formatVariantName(selectedVariant)
+    if (isDisabled && !isReasoningModel) return ""
+    return "Default"
+  }
+
+  return (
+    <div className="relative" ref={dropdownRef}>
+      <button
+        onClick={toggle}
+        disabled={isDisabled}
+        className="h-6 px-2 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
+        title="Select reasoning effort"
+      >
+        {/* Sparkles icon */}
+        <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+          <path
+            strokeLinecap="round"
+            strokeLinejoin="round"
+            strokeWidth={2}
+            d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
+          />
+        </svg>
+        {getCurrentDisplay()}
+        <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+        </svg>
+      </button>
+
+      {isOpen && (
+        <div className="absolute bottom-full left-0 mb-1 min-w-[140px] bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 max-h-96 overflow-hidden flex flex-col">
+          {/* Options list */}
+          <div className="overflow-y-auto flex-1">
+            {/* Default option */}
+            <button
+              onClick={() => handleSelect(undefined)}
+              className={`w-full px-3 py-2 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-between border-b border-gray-100 dark:border-gray-800 ${
+                selectedVariant === undefined
+                  ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
+                  : "text-gray-900 dark:text-gray-100"
+              }`}
+            >
+              <span className="font-medium">Default</span>
+              {selectedVariant === undefined && (
+                <svg className="w-4 h-4 ml-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
+                  <path
+                    fillRule="evenodd"
+                    d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+                    clipRule="evenodd"
+                  />
+                </svg>
+              )}
+            </button>
+
+            {/* Variant options */}
+            {variants?.map((variant) => {
+              const isSelected = selectedVariant === variant
+
+              return (
+                <button
+                  key={variant}
+                  onClick={() => handleSelect(variant)}
+                  className={`w-full px-3 py-2 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-between border-b border-gray-100 dark:border-gray-800 last:border-0 ${
+                    isSelected
+                      ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
+                      : "text-gray-900 dark:text-gray-100"
+                  }`}
+                >
+                  <span className="font-medium">{formatVariantName(variant)}</span>
+                  {isSelected && (
+                    <svg className="w-4 h-4 ml-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
+                      <path
+                        fillRule="evenodd"
+                        d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+                        clipRule="evenodd"
+                      />
+                    </svg>
+                  )}
+                </button>
+              )
+            })}
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 1 - 0
packages/opencode/webgui/src/lib/api/sdkClient.ts

@@ -18,6 +18,7 @@ interface StateResponse {
   provider?: string
   model?: string
   agent?: string
+  variant?: Record<string, string>
   recently_used_models?: Array<{
     provider_id: string
     model_id: string

+ 66 - 1
packages/opencode/webgui/src/state/SessionContext.tsx

@@ -45,6 +45,10 @@ interface SessionContextState {
   setSelectedModel: (providerId: string | undefined, modelId: string | undefined) => Promise<void>
   setSelectedAgent: (agent: string) => Promise<void>
 
+  // Variant selection (per provider/model combo)
+  selectedVariant: string | undefined
+  setSelectedVariant: (variant: string | undefined) => Promise<void>
+
   // Virtual session tracking
   isVirtualSession: boolean
 
@@ -128,6 +132,10 @@ export function SessionProvider({ children }: SessionProviderProps) {
   const [selectedAgent, setSelectedAgentState] = useState<string>("build")
   const [agentModelMap, setAgentModelMap] = useState<Record<string, { provider_id: string; model_id: string }>>({})
 
+  // Variant selection state (per provider/model combo, key = "providerId/modelId")
+  const [selectedVariant, setSelectedVariantState] = useState<string | undefined>()
+  const [variantMap, setVariantMap] = useState<Record<string, string>>({})
+
   const isReasoning = currentSession?.id ? Boolean(reasoningMap[currentSession.id]) : false
   const currentStatus: SessionStatusInfo =
     currentSession?.id && statusMap[currentSession.id]
@@ -166,6 +174,11 @@ export function SessionProvider({ children }: SessionProviderProps) {
             setAgentModelMap(state.agent_model)
           }
 
+          // Load variant map from server state
+          if (state.variant) {
+            setVariantMap(state.variant)
+          }
+
           // Set agent (default to 'build' if not set)
           const agent = state.agent || "build"
           setSelectedAgentState(agent)
@@ -201,6 +214,12 @@ export function SessionProvider({ children }: SessionProviderProps) {
             setSelectedModelId(modelId)
             localStorage.setItem("opencode_selected_provider", providerId)
             localStorage.setItem("opencode_selected_model", modelId)
+
+            // Compute initial variant for the selected model
+            if (state.variant) {
+              const modelKey = `${providerId}/${modelId}`
+              setSelectedVariantState(state.variant[modelKey])
+            }
           }
         }
       } catch (err) {
@@ -228,6 +247,14 @@ export function SessionProvider({ children }: SessionProviderProps) {
       setSelectedProviderId(providerId)
       setSelectedModelId(modelId)
 
+      // Restore variant for the new model
+      if (providerId && modelId) {
+        const modelKey = `${providerId}/${modelId}`
+        setSelectedVariantState(variantMap[modelKey])
+      } else {
+        setSelectedVariantState(undefined)
+      }
+
       // Persist to localStorage as fallback
       if (providerId) {
         localStorage.setItem("opencode_selected_provider", providerId)
@@ -279,7 +306,43 @@ export function SessionProvider({ children }: SessionProviderProps) {
         }
       }
     },
-    [selectedAgent, agentModelMap],
+    [selectedAgent, agentModelMap, variantMap],
+  )
+
+  /**
+   * Set selected variant and persist to server
+   * Updates per-model variant preference
+   */
+  const setSelectedVariant = useCallback(
+    async (variant: string | undefined) => {
+      setSelectedVariantState(variant)
+
+      // Get current model key
+      if (selectedProviderId && selectedModelId) {
+        const modelKey = `${selectedProviderId}/${selectedModelId}`
+
+        // Update variant map
+        let updatedVariantMap = { ...variantMap }
+        if (variant) {
+          updatedVariantMap[modelKey] = variant
+        } else {
+          delete updatedVariantMap[modelKey]
+        }
+        setVariantMap(updatedVariantMap)
+
+        // Persist to server
+        try {
+          await sdk.state.update({
+            body: {
+              variant: updatedVariantMap,
+            },
+          })
+        } catch (err) {
+          console.error("[SessionContext] Failed to save variant preference:", err)
+        }
+      }
+    },
+    [selectedProviderId, selectedModelId, variantMap],
   )
 
   /**
@@ -876,6 +939,8 @@ export function SessionProvider({ children }: SessionProviderProps) {
     selectedAgent,
     setSelectedModel,
     setSelectedAgent,
+    selectedVariant,
+    setSelectedVariant,
     isVirtualSession,
     newVirtual,
     createSession,