diff --git a/frontend/app/[locale]/models/components/model/ModelMigrationBadge.tsx b/frontend/app/[locale]/models/components/model/ModelMigrationBadge.tsx new file mode 100644 index 0000000000..d0c2775b5d --- /dev/null +++ b/frontend/app/[locale]/models/components/model/ModelMigrationBadge.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { ModelOption } from "@/types/modelConfig"; + +export function ModelMigrationBadge({ + model, +}: { + model: Pick; +}) { + const { t } = useTranslation(); + if (!model.migrationStatus) return null; + const state = + model.migrationStatus === "failed" + ? "failed" + : model.migrationReviewRequired + ? "pending" + : "reviewed"; + return ( + + + + {t(`modelMigration.${state}`)} + + + {t(`modelMigration.${state}Hint`)} + + ); +} diff --git a/frontend/app/[locale]/models/components/model/ModelMigrationReviewButton.tsx b/frontend/app/[locale]/models/components/model/ModelMigrationReviewButton.tsx new file mode 100644 index 0000000000..cb7a523699 --- /dev/null +++ b/frontend/app/[locale]/models/components/model/ModelMigrationReviewButton.tsx @@ -0,0 +1,202 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Alert, App, Descriptions, Modal } from "antd"; +import { useTranslation } from "react-i18next"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { StandardButton } from "@/components/common/StandardButton"; +import { + confirmModelOutletMigration, + getModelOutlet, +} from "@/services/modelProfileService"; +import type { ModelOption } from "@/types/modelConfig"; + +function object(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function ModelMigrationReviewButton({ + model, + onReviewed, +}: { + model: ModelOption; + onReviewed?: () => void; +}) { + const { t } = useTranslation(); + const { message } = App.useApp(); + const { user } = useAuthorizationContext(); + const [open, setOpen] = useState(false); + const admin = user?.role === "SU" || user?.role === "ADMIN"; + const outlet = useQuery({ + queryKey: ["model-outlet-review", model.id], + queryFn: ({ signal }) => getModelOutlet(model.id, signal), + enabled: open && admin && model.migrationStatus === "migrated", + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: Infinity, + }); + const confirm = useMutation({ + mutationFn: async () => { + if (!outlet.data) throw new Error("Missing displayed outlet revision"); + await confirmModelOutletMigration(model.id, outlet.data.config_revision); + }, + onSuccess: () => { + setOpen(false); + onReviewed?.(); + message.success(t("modelMigration.confirmed")); + }, + onError: () => { + message.error(t("modelMigration.confirmFailed")); + }, + }); + if (!admin || !model.migrationStatus) return null; + const profile = object(outlet.data?.profile); + const identity = object(profile.identity); + const connection = object(profile.connection); + const capabilities = object(profile.capabilities); + const context = object(capabilities.context); + const printable = (value: unknown) => + value === undefined || value === null + ? t("modelMigration.unknown") + : JSON.stringify(value); + return ( + <> + { + setOpen(true); + if (model.migrationStatus === "migrated") void outlet.refetch(); + }} + > + {t("modelMigration.review")} + + !confirm.isPending && setOpen(false)} + footer={ + model.migrationStatus === "migrated" ? ( + confirm.mutate()} + > + {t("modelMigration.confirm")} + + ) : null + } + > + + {model.migrationStatus === "failed" && ( +

{model.migrationDiagnostic?.category}

+ )} + {outlet.isFetching && ( +

{t("modelProfile.previewLoading")}

+ )} + {outlet.isError && ( + + )} + {outlet.data && ( + + {JSON.stringify(profile.parameter_values, null, 2)} + + ), + }, + { + key: "extras", + label: t("modelProfile.extraParameters"), + children: ( +
+                    {JSON.stringify(profile.extra_api_parameters, null, 2)}
+                  
+ ), + }, + ]} + /> + )} +
+ + ); +} diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index 0053432564..5675427cf8 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -3,13 +3,14 @@ "use client"; import i18next from "i18next"; +import { + persistModelDrafts, + snapshotModelDrafts, + acknowledgeModelDrafts, +} from "@/lib/conversationModelDrafts"; import type { ReasoningEffort } from "@/types/modelConfig"; import { isReasoningEffort } from "@/lib/modelReasoning"; -import { - snapshotTurnModelControls, - type TurnModelControls, - type RuntimeGenerationOverrides, -} from "@/features/model-controls/turnControls"; +import type { TurnModelControls } from "@/features/model-controls/turnControls"; import { rejectModelSelection } from "@/features/model-controls/rejectedSelection"; import type { @@ -270,9 +271,7 @@ export type Nl2aRecommendedResource = }; export type Nl2aPromptField = - | "duty_prompt" - | "greeting_message" - | "example_questions"; + "duty_prompt" | "greeting_message" | "example_questions"; export type Nl2aPayload = | Nl2aRequirementClarificationPayload @@ -288,6 +287,7 @@ export interface Nl2aMessage { } interface NexentRunConfig { + modelPreferenceScope?: string; threadId?: string; onServerConversationId?: (serverId: string, initialQuestion?: string) => void; onGenerationStopped?: (conversationId: number) => void; @@ -1391,14 +1391,11 @@ export const remoteChatModelAdapter: ChatModelAdapter = { const creationConfig = custom?.creationWorkbenchConfig ? structuredClone(custom.creationWorkbenchConfig) : undefined; - const creatorTurn = custom?.turnModelControls - ? structuredClone(custom.turnModelControls) - : creationConfig?.model_id != null - ? snapshotTurnModelControls( - creationConfig.model_id, - creationConfig.generation_config - ) - : undefined; + const creatorTurn = custom?.turnModelControls ?? undefined; + const preferenceScope = custom?.modelPreferenceScope ?? unstable_threadId; + const draftSnapshot = preferenceScope + ? snapshotModelDrafts(preferenceScope) + : {}; let nl2AgentId = typeof custom?.agentId === "string" ? Number(custom.agentId) @@ -1529,33 +1526,6 @@ export const remoteChatModelAdapter: ChatModelAdapter = { // Pass selected model if provided via ModelContext (registered by ModelSelector) // For agent-debug mode, prefer the model passed via custom (from the compare panel selector) const modelName = context.config?.modelName; - const reasoningEffort = context.config?.reasoningEffort; - const reasoningBudgetTokens = ( - context.config as { reasoningBudgetTokens?: number } | undefined - )?.reasoningBudgetTokens; - const reasoningCapability = ( - context.config as - | { - reasoningCapability?: { - status?: string; - controls?: Array<{ type?: string }>; - levels?: string[]; - control?: string; - }; - } - | undefined - )?.reasoningCapability; - const reasoningSupported = - reasoningCapability?.status === "supported" && - Boolean( - reasoningCapability.controls?.length || - reasoningCapability.levels?.length || - reasoningCapability.control === "toggle" - ); - const hasBudgetSelection = - typeof reasoningBudgetTokens === "number" && - Number.isInteger(reasoningBudgetTokens) && - reasoningBudgetTokens >= 0; const modelIdFromCustom = custom?.modelId; if (isAgentDebug && modelIdFromCustom) { @@ -1565,91 +1535,21 @@ export const remoteChatModelAdapter: ChatModelAdapter = { // Normal mode: use the model from ModelContext requestBody.model_id = Number(modelName); } - if ( - !isResume && - reasoningSupported && - !hasBudgetSelection && - typeof reasoningEffort === "string" && - reasoningEffort && - reasoningEffort !== "auto" - ) { - requestBody.reasoning_effort = reasoningEffort; - } - if ( - !isResume && - reasoningSupported && - hasBudgetSelection && - reasoningBudgetTokens > 0 - ) { - requestBody.reasoning_budget_tokens = reasoningBudgetTokens; - } - - log.log( - "[ChatModelAdapter] Sending agent request through conversation service" - ); - log.log( - `[ChatModelAdapter] model_id=${requestBody.model_id}, isAgentDebug=${isAgentDebug}, customModelId=${modelIdFromCustom}` - ); - - // Workbench v3 owns model selection; legacy composer state is only a projection. const workbenchConfig = custom?.workbenchConfig; - if (workbenchConfig) { - requestBody.model_id = workbenchConfig.model_id ?? undefined; - // Workbench generation_config is authoritative for this run. Do not let - // the shared selector's ordinary-chat effort/budget override it. - delete requestBody.reasoning_effort; - delete requestBody.reasoning_budget_tokens; - } - if (!workbenchConfig) { - const modelContext = context.config as - | { - deepThinking?: boolean; - reasoningEffort?: string; - temperature?: number; - top_p?: number; - } - | undefined; - if (modelContext?.deepThinking !== undefined) { - requestBody.generation_config = { - deep_thinking: modelContext.deepThinking, - ...(hasBudgetSelection - ? { reasoning_budget_tokens: reasoningBudgetTokens } - : {}), - ...(modelContext.deepThinking && - !hasBudgetSelection && - isReasoningEffort(modelContext.reasoningEffort) - ? { thinking_effort: modelContext.reasoningEffort } - : {}), - }; - } - if ( - !isAgentDebug && - !isResume && - (modelContext?.temperature != null || modelContext?.top_p != null) - ) { - requestBody.generation_config = { - ...(requestBody.generation_config as - RuntimeGenerationOverrides | undefined), - ...snapshotTurnModelControls( - Number(requestBody.model_id), - modelContext - ).generation_config, - }; - } - } - if (isNl2Agent || isNl2Skill) { - const turn = creatorTurn; - if (turn) { - requestBody.model_id = turn.model_id; - requestBody.generation_config = { ...turn.generation_config }; + if (creatorTurn && (isNl2Agent || isNl2Skill)) + requestBody.model_id = creatorTurn.model_id; + if (abortSignal?.aborted) return; + if (!isResume && preferenceScope) { + if (hasServerConversationId && !isAgentDebug) { + await persistModelDrafts(preferenceScope, numericServerThreadId); + } else { + requestBody.model_parameter_preferences = draftSnapshot; } - // One authority per creator turn; compatibility top-level fields are not duplicated. - delete requestBody.reasoning_effort; - delete requestBody.reasoning_budget_tokens; } + if (abortSignal?.aborted) return; - let backendConversationId = hasServerConversationId - ? numericServerThreadId + let backendConversationId = requestBody.conversation_id + ? Number(requestBody.conversation_id) : null; let backendRunId: string | null = null; let backendStopPromise: Promise | null = null; @@ -1741,8 +1641,9 @@ export const remoteChatModelAdapter: ChatModelAdapter = { draft_snapshot: isNl2Skill ? custom?.draftSnapshot : undefined, language: isNl2Skill ? custom?.language : undefined, model_id: requestBody.model_id as number | undefined, - generation_config: requestBody.generation_config as - RuntimeGenerationOverrides | undefined, + model_parameter_preferences: + requestBody.model_parameter_preferences as + Record> | undefined, metadata: custom?.runtimeMetadata, expected_metadata_version: custom?.runtimeMetadataVersion, entrypoint: workbenchConfig ? "workbench" : undefined, @@ -1752,6 +1653,8 @@ export const remoteChatModelAdapter: ChatModelAdapter = { abortSignal, (conversationId) => { const numericId = Number(conversationId); + if (numericId > 0 && preferenceScope) + acknowledgeModelDrafts(preferenceScope, draftSnapshot); if (!Number.isNaN(numericId) && numericId > 0) { backendConversationId = numericId; if (userAborted) { diff --git a/frontend/app/[locale]/newchat/assistant-ui/chat.tsx b/frontend/app/[locale]/newchat/assistant-ui/chat.tsx index 9dc2ff8b67..c8dfea11ea 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/chat.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/chat.tsx @@ -3,7 +3,6 @@ "use client"; import type { ReasoningEffort } from "@/types/modelConfig"; -import type { ModelParameterSettingsControl } from "@/types/modelParameterSettings"; import type { FC, ReactNode } from "react"; import { useCallback, useMemo } from "react"; @@ -31,17 +30,10 @@ export interface ChatProps { chatMode?: ChatMode; onChatModeChange?: (mode: ChatMode) => void; showModelSelector?: boolean; - modelParameterSettings?: ModelParameterSettingsControl; modelSelectionScope?: "agent" | "tenant"; fallbackAgentName?: string; selectedModelId?: string; onModelChange?: (modelId: string) => void; - deepThinking?: boolean; - onDeepThinkingChange?: (enabled: boolean) => void; - thinkingEffort?: ReasoningEffort; - onThinkingEffortChange?: (effort: ReasoningEffort) => void; - reasoningBudgetTokens?: number; - onReasoningBudgetTokensChange?: (budget: number) => void; showConversationTitle?: boolean; composerTools?: ReactNode; knowledgeScope?: ConversationKnowledgeScope | null; @@ -95,17 +87,10 @@ export const Chat: FC = ({ chatMode = "execution", onChatModeChange, showModelSelector = true, - modelParameterSettings, modelSelectionScope = "agent", fallbackAgentName = "Nexent Workbench", selectedModelId, onModelChange, - deepThinking, - onDeepThinkingChange, - thinkingEffort, - onThinkingEffortChange, - reasoningBudgetTokens, - onReasoningBudgetTokensChange, showConversationTitle = true, composerTools, knowledgeScope = null, @@ -161,14 +146,7 @@ export const Chat: FC = ({ welcomeContent={landingContent} selectedModelId={selectedModelId} onModelChange={onModelChange} - deepThinking={deepThinking} - onDeepThinkingChange={onDeepThinkingChange} - thinkingEffort={thinkingEffort} - onThinkingEffortChange={onThinkingEffortChange} - reasoningBudgetTokens={reasoningBudgetTokens} - onReasoningBudgetTokensChange={onReasoningBudgetTokensChange} showModelSelector={showModelSelector} - modelParameterSettings={modelParameterSettings} modelSelectionScope={modelSelectionScope} chatMode={chatMode} onChatModeChange={onChatModeChange} @@ -208,16 +186,9 @@ export const Chat: FC = ({ chatMode={chatMode} onChatModeChange={onChatModeChange} showModelSelector={showModelSelector} - modelParameterSettings={modelParameterSettings} modelSelectionScope={modelSelectionScope} selectedModelId={selectedModelId} onModelChange={onModelChange} - deepThinking={deepThinking} - onDeepThinkingChange={onDeepThinkingChange} - thinkingEffort={thinkingEffort} - onThinkingEffortChange={onThinkingEffortChange} - reasoningBudgetTokens={reasoningBudgetTokens} - onReasoningBudgetTokensChange={onReasoningBudgetTokensChange} showConversationTitle={showConversationTitle} composerTools={composerTools} knowledgeScope={knowledgeScope} diff --git a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx index a66a17d98c..13e58727f9 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx @@ -16,7 +16,6 @@ import { useTranslation } from "react-i18next"; import { App } from "antd"; import log from "@/lib/logger"; import { SelectedResourceChips } from "@/features/workbench/components/SelectedResourceChips"; -import type { ModelParameterSettingsControl } from "@/types/modelParameterSettings"; import { SkillPicker } from "@/features/workbench/components/SkillPicker"; import type { WorkbenchSkillMount } from "@/features/workbench/types"; import { CreationExamples } from "@/features/workbench/components/CreationExamples"; @@ -136,19 +135,13 @@ const KnowledgeBookIcon: FC<{ className?: string }> = ({ className }) => ( export type ChatMode = "planning" | "execution"; export interface ComposerProps { + conversationId?: number; models: readonly ModelOption[]; selectedModelId?: string; onModelChange?: (modelId: string) => void; - deepThinking?: boolean; - onDeepThinkingChange?: (enabled: boolean) => void; - thinkingEffort?: ReasoningEffort; - onThinkingEffortChange?: (effort: ReasoningEffort) => void; - reasoningBudgetTokens?: number; - onReasoningBudgetTokensChange?: (budget: number) => void; chatMode: ChatMode; onChatModeChange?: (mode: ChatMode) => void; showModelSelector?: boolean; - modelParameterSettings?: ModelParameterSettingsControl; children?: ReactNode; knowledgeScope?: ConversationKnowledgeScope | null; knowledgePreview?: KnowledgeScopeEffectivePreview | null; @@ -289,19 +282,13 @@ const SkillComposerDirectiveChip: FC = ({ ); export const Composer: FC = ({ + conversationId, models, selectedModelId, onModelChange, - deepThinking, - onDeepThinkingChange, - thinkingEffort, - onThinkingEffortChange, - reasoningBudgetTokens, - onReasoningBudgetTokensChange, chatMode, onChatModeChange, showModelSelector = true, - modelParameterSettings, children, knowledgeScope = null, knowledgePreview = null, @@ -349,7 +336,11 @@ export const Composer: FC = ({ }); thread.composer.setRunConfig({ ...config, - custom: { ...config.custom, ...feedback }, + custom: { + ...config.custom, + ...feedback, + modelPreferenceScope: modelParameterScope, + }, }); }; const [skillPickerOpen, setSkillPickerOpen] = useState(false); @@ -819,23 +810,14 @@ export const Composer: FC = ({
{showModelSelector && ( { - if (isReasoningEffort(value)) - onThinkingEffortChange?.(value); - }} variant="ghost" size="sm" className="min-w-0 text-sm font-normal leading-5 text-muted-foreground [&_[data-slot=model-selector-value]]:text-sm [&_[data-slot=model-selector-value]]:leading-5 [&_[data-slot=model-selector-value]]:text-muted-foreground [&_[data-slot=model-selector-value]_span]:font-normal" diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index f9e421d1af..fea1905aaa 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -5,7 +5,6 @@ import { useConversationModelControls } from "@/features/model-controls/useConversationModelControls"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import type { ReasoningEffort } from "@/types/modelConfig"; -import type { ModelParameterSettingsControl } from "@/types/modelParameterSettings"; import type { SourceAttributes } from "@/types/source"; import { @@ -205,16 +204,9 @@ export interface ThreadProps { onBack?: () => void; selectedModelId?: string; onModelChange?: (modelId: string) => void; - deepThinking?: boolean; - onDeepThinkingChange?: (enabled: boolean) => void; - thinkingEffort?: ReasoningEffort; - onThinkingEffortChange?: (effort: ReasoningEffort) => void; - reasoningBudgetTokens?: number; - onReasoningBudgetTokensChange?: (budget: number) => void; chatMode: ChatMode; onChatModeChange?: (mode: ChatMode) => void; showModelSelector?: boolean; - modelParameterSettings?: ModelParameterSettingsControl; modelSelectionScope?: ModelSelectionScope; showConversationTitle?: boolean; composerTools?: ReactNode; @@ -271,16 +263,9 @@ export const Thread: FC = ({ onBack, selectedModelId, onModelChange, - deepThinking, - onDeepThinkingChange, - thinkingEffort, - onThinkingEffortChange, - reasoningBudgetTokens, - onReasoningBudgetTokensChange, chatMode, onChatModeChange, showModelSelector = true, - modelParameterSettings, modelSelectionScope = "agent", showConversationTitle = true, composerTools, @@ -314,7 +299,8 @@ export const Thread: FC = ({ models, `${user?.tenantId ?? ""}:${user?.id ?? ""}:${agent.id}:${threadKey ?? "new"}`, selectedModelId, - onModelChange ? (selectedModelId ?? "") : undefined + onModelChange ? (selectedModelId ?? "") : undefined, + conversationId ); const effectiveSelectedModelId = onModelChange ? (selectedModelId ?? "") @@ -529,27 +515,9 @@ export const Thread: FC = ({ models={models} selectedModelId={modelIsValid ? effectiveSelectedModelId : ""} onModelChange={handleModelChange} - deepThinking={deepThinking ?? controls.deepThinking} - onDeepThinkingChange={ - onDeepThinkingChange ?? controls.onDeepThinkingChange - } - thinkingEffort={thinkingEffort ?? controls.thinkingEffort} - onThinkingEffortChange={ - onThinkingEffortChange ?? controls.onThinkingEffortChange - } - reasoningBudgetTokens={ - reasoningBudgetTokens ?? controls.reasoningBudgetTokens - } - onReasoningBudgetTokensChange={ - onReasoningBudgetTokensChange ?? - controls.onReasoningBudgetTokensChange - } chatMode={chatMode} onChatModeChange={onChatModeChange} showModelSelector={showModelSelector} - modelParameterSettings={ - modelParameterSettings ?? controls.modelParameterSettings - } showConversationTitle={showConversationTitle} composerTools={composerTools} knowledgeScope={knowledgeScope} @@ -563,7 +531,9 @@ export const Thread: FC = ({ onRuntimeMetadataChange={onRuntimeMetadataChange} readOnly={readOnly || (showModelSelector && !modelIsValid)} modelSelectionDisabled={readOnly} - readOnlyReason={readOnlyReason} + readOnlyReason={ + readOnlyReason ?? (showModelSelector ? controls.error : undefined) + } showComposer={showComposer} configurationLayout={configurationLayout} debugLayout={debugLayout} @@ -657,16 +627,9 @@ interface ThreadViewProps { models: readonly ModelOption[]; selectedModelId?: string; onModelChange?: (modelId: string) => void; - deepThinking?: boolean; - onDeepThinkingChange?: (enabled: boolean) => void; - thinkingEffort?: ReasoningEffort; - onThinkingEffortChange?: (effort: ReasoningEffort) => void; - reasoningBudgetTokens?: number; - onReasoningBudgetTokensChange?: (budget: number) => void; chatMode: ChatMode; onChatModeChange?: (mode: ChatMode) => void; showModelSelector: boolean; - modelParameterSettings?: ModelParameterSettingsControl; showConversationTitle: boolean; composerTools?: ReactNode; knowledgeScope: ConversationKnowledgeScope | null; @@ -722,16 +685,9 @@ const ThreadView: FC = ({ models, selectedModelId, onModelChange, - deepThinking, - onDeepThinkingChange, - thinkingEffort, - onThinkingEffortChange, - reasoningBudgetTokens, - onReasoningBudgetTokensChange, chatMode, onChatModeChange, showModelSelector, - modelParameterSettings, showConversationTitle, composerTools, knowledgeScope, @@ -999,20 +955,14 @@ const ThreadView: FC = ({ emptyFooterContent ))} void; newChatDesign?: boolean; - /** Accepted for backward compatibility; the design removed the legacy switch. */ - showLegacySwitch?: boolean; /** Workbench uses user-scoped server IDs; undefined retains Agent-chat pins. */ pinStorageKey?: string | null; } diff --git a/frontend/app/[locale]/newchat/ui/model-selector.tsx b/frontend/app/[locale]/newchat/ui/model-selector.tsx index cce5288fda..f6d59ddf7e 100644 --- a/frontend/app/[locale]/newchat/ui/model-selector.tsx +++ b/frontend/app/[locale]/newchat/ui/model-selector.tsx @@ -12,28 +12,16 @@ import { createContext, useContext, type ComponentPropsWithoutRef, - type KeyboardEvent, type ReactNode, } from "react"; import { cva, type VariantProps } from "class-variance-authority"; -import { ModelParameterSettings } from "@/components/chat/ModelParameterSettings"; -import { DEFAULT_MODEL_PARAMETER_VALUES } from "@/const/modelParameterSettings"; -import type { - ModelParameterValue, - ModelParameterSettingsControl, -} from "@/types/modelParameterSettings"; +import { ConversationModelParameters } from "@/components/model/ConversationModelParameters"; +import type { ModelParameterValue } from "@/types/modelParameterSettings"; import { CheckIcon, ChevronDownIcon, CpuIcon } from "lucide-react"; import { useAui } from "@assistant-ui/react"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import type { ReasoningCapability } from "@/types/modelConfig"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Popover, PopoverContent, @@ -61,13 +49,9 @@ export const DEFAULT_EFFORT_OPTIONS: readonly ModelSelectorEffortOption[] = [ { id: "high", name: "High" }, ]; -const getReasoningEffortName = ( - option: ModelSelectorEffortOption, - t: (key: string, options?: Record) => string -): string => - t(`chat.modelSelector.effort.${option.id}`, { defaultValue: option.name }); - export type ModelOption = { + profileSchemaVersion?: number; + agentParameterDefaults?: Record; id: string; name: string; description?: string; @@ -93,37 +77,6 @@ export type ModelOption = { reasoningCapability?: ReasoningCapability; }; -function getModelEfforts( - model: ModelOption | undefined -): readonly ModelSelectorEffortOption[] | undefined { - if (!model?.efforts) return undefined; - return model.efforts === true ? DEFAULT_EFFORT_OPTIONS : model.efforts; -} - -function resolveEffort( - efforts: readonly ModelSelectorEffortOption[] | undefined, - effort: string | undefined -): string | undefined { - if (effort === undefined) return undefined; - return efforts?.some((e) => e.id === effort) ? effort : undefined; -} - -/** - * Returns the effort id if the given model supports it, otherwise undefined. - * Effort selection is kept sticky across model switches; this resolves what - * actually applies to the current model. - */ -export function resolveModelEffort( - models: readonly ModelOption[], - modelId: string | undefined, - effort: string | undefined -): string | undefined { - return resolveEffort( - getModelEfforts(models.find((m) => m.id === modelId)), - effort - ); -} - function useControllableState({ prop, defaultProp, @@ -158,16 +111,6 @@ type ModelSelectorContextValue = { setValue: (value: string) => void; /** The model matching `value`, derived once for all sub-components. */ selectedModel: ModelOption | undefined; - /** The selected model's effort levels, undefined when not configurable. */ - efforts: readonly ModelSelectorEffortOption[] | undefined; - /** Effort resolved against the selected model's supported levels. */ - effort: string | undefined; - setEffort: (effort: string) => void; - deepThinking: boolean; - setDeepThinking: (enabled: boolean) => void; - showDeepThinkingControl: boolean; - budgetTokens: number | undefined; - setBudgetTokens: (budget: number) => void; setOpen: (open: boolean) => void; }; @@ -185,35 +128,11 @@ function useModelSelectorContext() { return ctx; } -/** - * The selected model's effort levels and the active selection. Use it to build - * a custom effort UI inside ModelSelector.Content (e.g. a slider or a shadcn - * DropdownMenu) when the built-in ModelSelector.Effort layout doesn't fit. - * `efforts` is undefined for models without configurable reasoning. - */ -export function useModelSelectorEfforts(): { - efforts: readonly ModelSelectorEffortOption[] | undefined; - effort: string | undefined; - setEffort: (effort: string) => void; -} { - const { efforts, effort, setEffort } = useModelSelectorContext(); - return { efforts, effort, setEffort }; -} - export type ModelSelectorRootProps = { models: readonly ModelOption[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; - effort?: string; - defaultEffort?: string; - onEffortChange?: (effort: string) => void; - deepThinking?: boolean; - defaultDeepThinking?: boolean; - onDeepThinkingChange?: (enabled: boolean) => void; - budgetTokens?: number; - defaultBudgetTokens?: number; - onBudgetTokensChange?: (budget: number) => void; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; @@ -225,15 +144,6 @@ function ModelSelectorRoot({ value: valueProp, defaultValue, onValueChange, - effort: effortProp, - defaultEffort, - onEffortChange, - deepThinking: deepThinkingProp, - defaultDeepThinking = false, - onDeepThinkingChange, - budgetTokens: budgetTokensProp, - defaultBudgetTokens, - onBudgetTokensChange, open: openProp, defaultOpen, onOpenChange, @@ -244,114 +154,21 @@ function ModelSelectorRoot({ defaultProp: defaultValue ?? models[0]?.id, onChange: onValueChange, }); - const [effort, setEffort] = useControllableState({ - prop: effortProp, - defaultProp: - defaultEffort ?? - models.find( - (model) => model.id === (valueProp ?? defaultValue ?? models[0]?.id) - )?.defaultEffort ?? - "low", - onChange: onEffortChange, - }); - const [deepThinking, setDeepThinking] = useControllableState({ - prop: deepThinkingProp, - defaultProp: defaultDeepThinking, - onChange: onDeepThinkingChange, - }); - const [budgetTokens, setBudgetTokens] = useControllableState({ - prop: budgetTokensProp, - defaultProp: - defaultBudgetTokens ?? - models.find( - (model) => model.id === (valueProp ?? defaultValue ?? models[0]?.id) - )?.defaultBudgetTokens, - onChange: onBudgetTokensChange, - }); const [open, setOpen] = useControllableState({ prop: openProp, defaultProp: defaultOpen ?? false, onChange: onOpenChange, }); - const selectedModel = models.find((m) => m.id === value); - // Keep the shared selector settings visible before a model is selected. - const showDeepThinkingControl = true; - - // Model lists are loaded asynchronously. Without this sync an initially - // empty list leaves the selector permanently unselected because - // useState(defaultProp) only evaluates on the first render. + const selectedModel = models.find((model) => model.id === value); useEffect(() => { - if (value === undefined && models[0]) { - setValue(models[0].id); - } + if (value === undefined && models[0]) setValue(models[0].id); }, [models, value, setValue]); - - const previousModelIdRef = useRef(value); - useEffect(() => { - if (effortProp !== undefined) { - previousModelIdRef.current = value; - return; - } - if (previousModelIdRef.current !== value) { - if (selectedModel?.defaultEffort !== undefined) { - setEffort(selectedModel.defaultEffort); - } - previousModelIdRef.current = value; - } - }, [effortProp, selectedModel?.defaultEffort, setEffort, value]); - - const efforts = getModelEfforts(selectedModel); - const resolvedDefaultEffort = - selectedModel?.defaultEffort && - efforts?.some((option) => option.id === selectedModel.defaultEffort) - ? selectedModel.defaultEffort - : undefined; - const activeEffort = - resolveEffort(efforts, effort) ?? resolvedDefaultEffort ?? efforts?.[0]?.id; - const budgetRange = selectedModel?.budgetTokens; - const activeBudgetTokens = !budgetRange - ? undefined - : budgetTokens !== undefined && budgetTokens > 0 - ? Math.min(budgetRange.max, Math.max(budgetRange.min, budgetTokens)) - : budgetTokens === 0 - ? 0 - : selectedModel?.defaultBudgetTokens; const contextValue = useMemo( - () => ({ - models, - value, - setValue, - selectedModel, - efforts, - effort: activeEffort, - setEffort, - deepThinking: deepThinking ?? false, - setDeepThinking, - showDeepThinkingControl, - budgetTokens: activeBudgetTokens, - setBudgetTokens, - setOpen, - }), - [ - models, - value, - setValue, - selectedModel, - efforts, - activeEffort, - setEffort, - deepThinking, - setDeepThinking, - showDeepThinkingControl, - activeBudgetTokens, - setBudgetTokens, - setOpen, - ] + () => ({ models, value, setValue, selectedModel, setOpen }), + [models, value, setValue, selectedModel, setOpen] ); - return ( - {/* `?? false` narrows away undefined for exactOptionalPropertyTypes consumers. */} {children} @@ -427,12 +244,10 @@ function ModelIcon({ children }: { children: ReactNode }) { function ModelSelectorValue({ placeholder, - showEffort = false, className, }: ModelSelectorValueProps) { const { t } = useTranslation(); - const { selectedModel, efforts, effort, deepThinking } = - useModelSelectorContext(); + const { selectedModel } = useModelSelectorContext(); const resolvedPlaceholder = placeholder ?? t("chat.modelSelector.selectModel"); @@ -447,22 +262,6 @@ function ModelSelectorValue({ ); } - const effortName = - showEffort && - deepThinking && - selectedModel.budgetTokens === undefined && - effort !== undefined - ? (() => { - const option = efforts?.find((e) => e.id === effort); - if (option) return getReasoningEffortName(option, t); - // Models without catalog effort options still show the current - // effort level next to the name (design: "DeepSeek 高"). - return t(`chat.modelSelector.effort.${effort}`, { - defaultValue: effort, - }); - })() - : undefined; - return ( {selectedModel.icon && {selectedModel.icon}} {selectedModel.name} - {effortName && ( - {effortName} - )} ); } @@ -525,7 +321,6 @@ function ModelSelectorContent({ {children ?? ( <> - )} @@ -669,146 +464,9 @@ function ModelSelectorItem({ ); } -export type ModelSelectorEffortProps = ComponentPropsWithoutRef<"div"> & { - label?: ReactNode; -}; - -function ModelSelectorEffort({ - label, - className, - onKeyDown, - ...props -}: ModelSelectorEffortProps) { - const { t } = useTranslation(); - const { - selectedModel, - efforts, - effort, - setEffort, - deepThinking, - setDeepThinking, - budgetTokens, - setBudgetTokens, - } = useModelSelectorContext(); - const resolvedLabel = label ?? t("chat.modelSelector.reasoningEffort"); - const displayedEfforts = efforts ?? []; - const budgetRange = selectedModel?.budgetTokens; - - return ( -
) => { - // cmdk must not turn Enter on a focused setting into model selection. - if (e.key === "Enter") e.stopPropagation(); - onKeyDown?.(e); - }} - {...props} - > -
- {t("chat.modelSelector.deepThinking")} - -
- {deepThinking && ( -
- - {budgetRange - ? t("model.advanced.reasoningBudget", { - defaultValue: "Budget tokens", - }) - : resolvedLabel} - - {budgetRange ? ( -
- - setBudgetTokens(Number(event.target.value)) - } - className="min-w-0 flex-1" - /> - - {budgetTokens || t("chat.modelSelector.effort.auto")} - - -
- ) : ( - - )} -
- )} -
- ); -} - export type ModelSelectorProps = { + conversationId?: number; disabled?: boolean; - parameterSettings?: ModelParameterSettingsControl; parameterSettingsScopeKey?: string; parameterSettingsDisabled?: boolean; } & Omit & @@ -823,103 +481,47 @@ export type ModelSelectorProps = { /** Registers the selection with assistant-ui's ModelContext system. The * context's effort is already resolved against the selected model. */ -function ModelSelectorModelContext({ - sampling, -}: { - sampling?: ModelParameterValue; -}) { - const { - selectedModel, - value, - effort, - budgetTokens, - deepThinking, - showDeepThinkingControl, - } = useModelSelectorContext(); +function ModelSelectorModelContext() { + const { value } = useModelSelectorContext(); const api = useAui(); - const budgetPreferred = selectedModel?.budgetTokens !== undefined; - useEffect(() => { if (value === undefined) return; - const config = { - config: { - modelName: value, - ...(showDeepThinkingControl ? { deepThinking } : undefined), - reasoningCapability: selectedModel?.reasoningCapability, - ...(sampling?.temperature != null - ? { temperature: sampling.temperature } - : {}), - ...(sampling?.top_p != null ? { top_p: sampling.top_p } : {}), - ...(deepThinking - ? budgetPreferred - ? budgetTokens !== undefined - ? { reasoningBudgetTokens: budgetTokens } - : undefined - : effort !== undefined - ? { reasoningEffort: effort } - : undefined - : undefined), - }, - }; - return api.modelContext().register({ - getModelContext: () => config, - }); - }, [ - api, - budgetTokens, - deepThinking, - effort, - budgetPreferred, - showDeepThinkingControl, - selectedModel?.reasoningCapability, - sampling?.temperature, - sampling?.top_p, - value, - ]); - + return api + .modelContext() + .register({ getModelContext: () => ({ config: { modelName: value } }) }); + }, [api, value]); return null; } export interface ModelSelectorAdvancedSettingsProps { - control?: ModelParameterSettingsControl; + conversationId?: number; scopeKey?: string; disabled?: boolean; } /** Controlled sampling settings with a scoped fallback for composed selectors. */ function ModelSelectorAdvancedSettings({ - control, + conversationId, scopeKey, disabled = false, }: ModelSelectorAdvancedSettingsProps) { - const { value, selectedModel } = useModelSelectorContext(); - const localScope = JSON.stringify([ - scopeKey ?? "default", - value ?? "default", - ]); - const [localValues, setLocalValues] = useState< - Record - >({}); - const resolved = control ?? { - scopeKey: localScope, - value: localValues[localScope] ?? DEFAULT_MODEL_PARAMETER_VALUES, - defaultValue: DEFAULT_MODEL_PARAMETER_VALUES, - onChange: (next: ModelParameterValue) => - setLocalValues((current) => ({ ...current, [localScope]: next })), - }; + const { selectedModel } = useModelSelectorContext(); + if (!selectedModel?.profileSchemaVersion) return null; return ( - ); } const ModelSelectorBody = ({ + conversationId, disabled = false, - parameterSettings, parameterSettingsScopeKey, parameterSettingsDisabled, searchable, @@ -929,21 +531,9 @@ const ModelSelectorBody = ({ className, contentClassName, }: ModelSelectorProps) => { - const { value } = useModelSelectorContext(); - const scope = JSON.stringify([parameterSettingsScopeKey ?? "default", value]); - const [sampling, setSampling] = useState>( - {} - ); - const resolved = parameterSettings ?? { - scopeKey: scope, - value: sampling[scope] ?? DEFAULT_MODEL_PARAMETER_VALUES, - defaultValue: DEFAULT_MODEL_PARAMETER_VALUES, - onChange: (next: ModelParameterValue) => - setSampling((current) => ({ ...current, [scope]: next })), - }; return ( <> - + {searchable && } - @@ -966,9 +555,12 @@ const ModelSelectorBody = ({ ); }; -const ModelSelectorImpl = (props: ModelSelectorProps) => ( +const ModelSelectorImpl = ({ + conversationId, + ...props +}: ModelSelectorProps) => ( - + ); @@ -984,7 +576,6 @@ type ModelSelectorComponent = typeof ModelSelectorImpl & { Group: typeof ModelSelectorGroup; Separator: typeof ModelSelectorSeparator; Item: typeof ModelSelectorItem; - Effort: typeof ModelSelectorEffort; AdvancedSettings: typeof ModelSelectorAdvancedSettings; }; @@ -1003,7 +594,6 @@ ModelSelector.Empty = ModelSelectorEmpty; ModelSelector.Group = ModelSelectorGroup; ModelSelector.Separator = ModelSelectorSeparator; ModelSelector.Item = ModelSelectorItem; -ModelSelector.Effort = ModelSelectorEffort; ModelSelector.AdvancedSettings = ModelSelectorAdvancedSettings; export { @@ -1018,6 +608,5 @@ export { ModelSelectorGroup, ModelSelectorSeparator, ModelSelectorItem, - ModelSelectorEffort, ModelSelectorAdvancedSettings, }; diff --git a/frontend/components/model/ConversationModelParameters.tsx b/frontend/components/model/ConversationModelParameters.tsx new file mode 100644 index 0000000000..e4fba265a6 --- /dev/null +++ b/frontend/components/model/ConversationModelParameters.tsx @@ -0,0 +1,374 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { parameterIsActive } from "@/lib/modelParameterLayers"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Alert, App, Form, Spin } from "antd"; +import { useTranslation } from "react-i18next"; +import { + ModelParameterPopover, + ModelSamplingFields, +} from "@/components/chat/ModelParameterSettings"; +import { + detectThinkingMode, + thinkingDegreePrefill, +} from "./ModelThinkingSettings"; +import { ModelThinkingToggle } from "@/components/chat/ModelThinkingToggle"; +import { StandardButton } from "@/components/common/StandardButton"; +import { + getModelOutlet, + getConversationModelPreferences, + patchConversationModelPreferences, +} from "@/services/modelProfileService"; +import { patchModelDraft, readModelDraft } from "@/lib/conversationModelDrafts"; +import type { ConversationModelPreferenceView } from "@/types/modelProfile"; +import { ModelParameterField } from "./ModelParameterField"; + +export function ConversationModelParameters({ + conversationId, + draftScope, + modelId, + disabled, + agentDefaults = {}, +}: { + conversationId?: number; + draftScope?: string; + modelId: number; + disabled?: boolean; + agentDefaults?: Record; +}) { + const { t } = useTranslation(); + const { message } = App.useApp(); + const client = useQueryClient(); + const [advancedOpen, setAdvancedOpen] = useState(false); + const queryKey = [ + "conversation-model-preferences", + conversationId ?? draftScope, + modelId, + ]; + const query = useQuery({ + queryKey, + queryFn: async ({ signal }) => { + if (conversationId) + return getConversationModelPreferences(conversationId, modelId, signal); + const outlet = await getModelOutlet(modelId, signal); + return { + version: 0, + config_revision: outlet.config_revision, + parameter_values: readModelDraft(draftScope!, modelId), + defaults: outlet.profile.parameter_values, + field_specs: outlet.field_specs ?? outlet.profile.parameter_rules, + issues: [], + output_limit: outlet.profile.capabilities?.context?.output_limit, + } satisfies ConversationModelPreferenceView; + }, + enabled: Boolean(conversationId || draftScope), + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: 0, + }); + const savePreferences = async (request: { + expected_version: number; + parameter_values: Record; + delete_parameters: string[]; + }) => { + if (conversationId) + await patchConversationModelPreferences(conversationId, modelId, request); + else if (draftScope) + patchModelDraft( + draftScope, + modelId, + request.parameter_values, + request.delete_parameters + ); + else throw new Error("MODEL_PREFERENCE_SCOPE_MISSING"); + await client.invalidateQueries({ + queryKey: conversationId + ? ["conversation-model-preferences", conversationId] + : queryKey, + }); + }; + const quick = useMutation({ + mutationFn: ({ + key, + value, + reset = false, + }: { + key: string; + value?: unknown; + reset?: boolean; + }) => { + const removed = + key === "enable_thinking" && value === false + ? query + .data!.field_specs.filter( + (rule) => + ["reasoning_effort", "reasoning_budget_tokens"].includes( + rule.key + ) && + rule.editable && + rule.allowed_scopes.includes("conversation") + ) + .map((rule) => rule.key) + : []; + const values: Record = reset ? {} : { [key]: value }; + if (key === "enable_thinking" && value === true) { + const effective = { + ...query.data!.defaults, + ...agentDefaults, + ...query.data!.parameter_values, + }; + const mode = detectThinkingMode(query.data!.field_specs, effective); + const degreeKey = + mode === "effort" ? "reasoning_effort" : "reasoning_budget_tokens"; + const rule = query.data!.field_specs.find( + (item) => + item.key === degreeKey && + item.editable && + item.allowed_scopes.includes("conversation") + ); + const prefill = + effective[degreeKey] ?? + thinkingDegreePrefill(rule, mode, query.data!.output_limit); + if (rule && effective[degreeKey] === undefined && prefill !== undefined) + values[degreeKey] = prefill; + } + return savePreferences({ + expected_version: query.data!.version, + parameter_values: values, + delete_parameters: reset ? [key] : removed, + }); + }, + onError: () => message.error(t("modelProfile.preferencesFailed")), + }); + const saved = query.data; + const defaults = { ...saved?.defaults, ...agentDefaults }; + const effective = { ...defaults, ...saved?.parameter_values }; + const thinkingRule = saved?.field_specs.find( + (rule) => + rule.key === "enable_thinking" && + rule.visible && + rule.editable && + rule.allowed_scopes.includes("conversation") + ); + const degreeRules = + saved?.field_specs.filter( + (rule) => + ["reasoning_effort", "reasoning_budget_tokens"].includes(rule.key) && + rule.visible && + rule.allowed_scopes.includes("conversation") && + parameterIsActive(rule.condition, effective) + ) ?? []; + const degree = + degreeRules.find((rule) => Object.hasOwn(effective, rule.key)) ?? + degreeRules[0]; + const pending = disabled || quick.isPending || query.isFetching; + return ( +
{ + if (event.key === "Enter") event.stopPropagation(); + }} + > +
+ quick.mutate({ key: "enable_thinking", value })} + /> + {effective.enable_thinking === true && degree && ( +
+ quick.mutate({ key, value })} + resetLabel={t("modelProfile.useInheritedDefault")} + onReset={(key) => quick.mutate({ key, reset: true })} + /> + + )} +
+ + {() => + query.isPending ? ( + + ) : query.isError ? ( + + ) : ( + saved && ( + + ) + ) + } + +
+ ); +} + +function PreferenceEditor({ + onSave, + saved, +}: { + onSave: (request: { + expected_version: number; + parameter_values: Record; + delete_parameters: string[]; + }) => Promise; + saved: ConversationModelPreferenceView; +}) { + const { t } = useTranslation(); + const { message } = App.useApp(); + const values = saved.parameter_values; + const save = useMutation({ + mutationFn: (patch: { + parameter_values: Record; + delete_parameters: string[]; + }) => onSave({ expected_version: saved.version, ...patch }), + onError: () => message.error(t("modelProfile.preferencesFailed")), + }); + const update = (key: string, value?: unknown) => + save.mutate({ + parameter_values: value === undefined ? {} : { [key]: value }, + delete_parameters: value === undefined ? [key] : [], + }); + const samplingKeys = ["temperature", "top_p"]; + const sampling = Object.fromEntries( + samplingKeys + .filter((key) => Object.hasOwn(values, key)) + .map((key) => [key, values[key]]) + ); + const rules = saved.field_specs.filter( + (rule) => + ![ + ...samplingKeys, + "enable_thinking", + "reasoning_effort", + "reasoning_budget_tokens", + ].includes(rule.key) && + rule.visible && + rule.allowed_scopes.includes("conversation") && + (parameterIsActive(rule.condition, { ...saved.defaults, ...values }) || + Object.hasOwn(values, rule.key)) + ); + return ( +
+ {!!saved.issues.length && ( + + )} + { + const rule = saved.field_specs.find((field) => field.key === key); + return ( + !rule?.editable || !rule.allowed_scopes.includes("conversation") + ); + })} + bounds={Object.fromEntries( + saved.field_specs + .filter((rule) => samplingKeys.includes(rule.key)) + .map((rule) => [ + rule.key, + { + min: + typeof rule.schema.minimum === "number" + ? rule.schema.minimum + : undefined, + max: + typeof rule.schema.maximum === "number" + ? rule.schema.maximum + : undefined, + }, + ]) + )} + onChange={(next) => + save.mutate({ + parameter_values: { ...next }, + delete_parameters: samplingKeys.filter( + (key) => Object.hasOwn(values, key) && !Object.hasOwn(next, key) + ), + }) + } + /> + {!!rules.length && ( +
+ {rules.map((rule) => ( + update(key)} + /> + ))} + + )} + {Object.keys(values) + .filter( + (key) => + !saved.field_specs.some( + (rule) => + rule.key === key && + rule.visible && + rule.allowed_scopes.includes("conversation") + ) + ) + .map((key) => ( + update(key)}> + {t("common.delete")} + + } + /> + ))} + {save.isError && ( + + )} +
+ ); +} diff --git a/frontend/features/model-controls/modelOptions.ts b/frontend/features/model-controls/modelOptions.ts index f0c9fa9ce2..14cd581375 100644 --- a/frontend/features/model-controls/modelOptions.ts +++ b/frontend/features/model-controls/modelOptions.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +import { agentParameterValues } from "@/lib/modelParameterLayers"; import type { Agent, PublishedAgent } from "@/types/agentConfig"; import type { ModelOption as CatalogModelOption, @@ -12,144 +13,6 @@ import type { WorkbenchSessionConfig } from "@/features/workbench/types"; export type ModelSelectionScope = "agent" | "tenant"; -function configuredEffort( - model?: Pick< - CatalogModelOption, - "defaultReasoningEffort" | "reasoningCapability" - > -): ReasoningEffort { - const value = model?.defaultReasoningEffort; - return isReasoningEffort(value) ? value : "auto"; -} - -/** Restore per-model overrides while keeping unrelated generation settings. */ -export function selectWorkbenchModel( - config: WorkbenchSessionConfig, - models: readonly CatalogModelOption[], - modelId: number, - remembered?: Pick< - WorkbenchSessionConfig["generation_config"], - | "deep_thinking" - | "thinking_effort" - | "reasoning_budget_tokens" - | "temperature" - | "top_p" - > -): WorkbenchSessionConfig { - const model = models.find((item) => item.id === modelId); - return withDefaultWorkbenchModel( - { - ...config, - model_id: modelId, - generation_config: { - ...config.generation_config, - temperature: - config.model_id === modelId - ? config.generation_config.temperature - : undefined, - top_p: - config.model_id === modelId - ? config.generation_config.top_p - : undefined, - deep_thinking: model?.enableThinking ?? false, - thinking_effort: configuredEffort(model), - reasoning_budget_tokens: - typeof model?.extraParams?.reasoning_budget_tokens === "number" - ? model.extraParams.reasoning_budget_tokens - : undefined, - ...remembered, - }, - }, - models - ); -} - -/** Keep the model displayed in Workbench identical to the model sent at run time. */ -export function withDefaultWorkbenchModel( - config: WorkbenchSessionConfig, - availableLlmModels: readonly Pick< - CatalogModelOption, - | "id" - | "reasoningCapability" - | "defaultReasoningEffort" - | "enableThinking" - | "extraParams" - >[] -): WorkbenchSessionConfig { - const modelId = config.model_id ?? availableLlmModels[0]?.id; - if (modelId == null) return config; - const model = availableLlmModels.find((item) => item.id === modelId); - if (!model) return config; - const generation = - config.model_id == null - ? { - ...config.generation_config, - deep_thinking: model.enableThinking ?? false, - thinking_effort: configuredEffort(model), - reasoning_budget_tokens: - typeof model.extraParams?.reasoning_budget_tokens === "number" - ? model.extraParams.reasoning_budget_tokens - : undefined, - } - : config.generation_config; - const capability = model?.reasoningCapability; - const budgetControl = - capability?.status === "supported" - ? capability.controls?.find((control) => control.type === "budget_tokens") - : undefined; - const effortControl = - capability?.status === "supported" - ? capability.controls?.find((control) => control.type === "effort") - : undefined; - const levels = - effortControl?.type === "effort" - ? effortControl.values.filter(isReasoningEffort) - : capability?.status === "supported" - ? capability.levels.filter(isReasoningEffort) - : []; - // Unknown capabilities never invent an effort; auto keeps provider defaults. - const effort = generation.thinking_effort; - const defaultCandidates: (ReasoningEffort | null | undefined)[] = [ - model.defaultReasoningEffort, - DEFAULT_REASONING_EFFORT, - ]; - const resolvedEffort = budgetControl - ? "auto" - : effort === "auto" || (effort && levels.includes(effort)) - ? effort - : (defaultCandidates.find( - (candidate) => - candidate === "auto" || - (candidate != null && levels.includes(candidate)) - ) ?? DEFAULT_REASONING_EFFORT); - const enabled = - capability?.status === "supported" ? generation.deep_thinking : false; - const rawBudget = generation.reasoning_budget_tokens; - const budget = - budgetControl?.type === "budget_tokens" - ? rawBudget && rawBudget > 0 - ? Math.min(budgetControl.max, Math.max(budgetControl.min, rawBudget)) - : 0 - : undefined; - if ( - modelId === config.model_id && - resolvedEffort === effort && - enabled === config.generation_config.deep_thinking && - budget === rawBudget - ) - return config; - return { - ...config, - model_id: modelId, - generation_config: { - ...generation, - thinking_effort: resolvedEffort, - deep_thinking: enabled, - reasoning_budget_tokens: budget, - }, - }; -} - export const deriveModelOptions = ( agent: Agent | PublishedAgent, availableModels: readonly CatalogModelOption[], @@ -164,6 +27,19 @@ export const deriveModelOptions = ( (item) => String(item.id) === id || item.name === id || item.displayName === id ); + if (model?.profileSchemaVersion) { + return { + id, + name: fallbackName, + profileSchemaVersion: model.profileSchemaVersion, + agentParameterDefaults: + scope === "agent" + ? agentParameterValues({ + ...typedAgent.model_params_override?.[id], + }) + : {}, + }; + } const extra = scope === "agent" ? typedAgent.model_params_override?.[id]?.extra_params diff --git a/frontend/features/model-controls/turnControls.ts b/frontend/features/model-controls/turnControls.ts index 7d778b6d88..c527f58267 100644 --- a/frontend/features/model-controls/turnControls.ts +++ b/frontend/features/model-controls/turnControls.ts @@ -1,54 +1,5 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import type { ReasoningEffort } from "@/types/modelConfig"; -import { isReasoningEffort } from "@/lib/modelReasoning"; - -export interface RuntimeGenerationOverrides { - deep_thinking?: boolean; - thinking_effort?: ReasoningEffort; - reasoning_budget_tokens?: number; - temperature?: number; - top_p?: number; -} - export interface TurnModelControls { model_id: number; - generation_config: RuntimeGenerationOverrides; -} - -/** Copy canonical runtime intent without introducing defaults or mutable references. */ -export function snapshotTurnModelControls( - modelId: string | number, - generation: RuntimeGenerationOverrides -): TurnModelControls { - const id = Number(modelId); - if (!Number.isSafeInteger(id) || id <= 0) - throw new Error("model_not_available"); - for (const [key, max] of [ - ["temperature", 2], - ["top_p", 1], - ] as const) { - const value = generation[key]; - if (value != null && (!Number.isFinite(value) || value < 0 || value > max)) - throw new Error("model_controls_invalid"); - } - return { - model_id: id, - generation_config: { - ...(typeof generation.deep_thinking === "boolean" - ? { deep_thinking: generation.deep_thinking } - : {}), - ...(isReasoningEffort(generation.thinking_effort) - ? { thinking_effort: generation.thinking_effort } - : {}), - ...(Number.isInteger(generation.reasoning_budget_tokens) && - (generation.reasoning_budget_tokens ?? -1) >= 0 - ? { reasoning_budget_tokens: generation.reasoning_budget_tokens } - : {}), - ...(generation.temperature != null - ? { temperature: generation.temperature } - : {}), - ...(generation.top_p != null ? { top_p: generation.top_p } : {}), - }, - }; } diff --git a/frontend/features/model-controls/useConversationModelControls.ts b/frontend/features/model-controls/useConversationModelControls.ts index 96fc56b171..d9defa020b 100644 --- a/frontend/features/model-controls/useConversationModelControls.ts +++ b/frontend/features/model-controls/useConversationModelControls.ts @@ -3,145 +3,113 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import { App } from "antd"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { + getConversationModelSelection, + patchConversationModelSelection, +} from "@/services/modelProfileService"; import type { ModelOption } from "@/app/newchat/ui/model-selector"; -import type { ReasoningEffort } from "@/types/modelConfig"; -import { isReasoningEffort } from "@/lib/modelReasoning"; -import { snapshotTurnModelControls } from "./turnControls"; -import { DEFAULT_MODEL_PARAMETER_VALUES } from "@/const/modelParameterSettings"; -import type { ModelParameterValue } from "@/types/modelParameterSettings"; import { subscribeRejectedSelection } from "./rejectedSelection"; - -interface ModelState { - enabled: boolean; - effort: ReasoningEffort; - budget: number; -} -interface SessionState { - modelId: string; - values: Record; -} +import { + readModelSelectionDraft, + selectModelDraft, +} from "@/lib/conversationModelDrafts"; export function useConversationModelControls( models: readonly ModelOption[], sessionKey: string, defaultModelId?: string, - controlledModelId?: string + controlledModelId?: string, + conversationId?: number ) { - const [sessions, setSessions] = useState>({}); - const [sampling, setSampling] = useState>( - {} - ); + const client = useQueryClient(); + const { message } = App.useApp(); + const { t } = useTranslation(); + const persisted = Boolean(conversationId && controlledModelId === undefined); + const queryKey = [ + "conversation-model-preferences", + conversationId, + "selection", + ]; + const selection = useQuery({ + queryKey, + queryFn: ({ signal }) => + getConversationModelSelection(conversationId!, signal), + enabled: persisted, + retry: false, + refetchOnWindowFocus: false, + }); + const save = useMutation({ + mutationFn: (modelId: string) => + patchConversationModelSelection( + conversationId!, + Number(modelId), + selection.data!.version + ), + onSuccess: async (result) => { + client.setQueryData(queryKey, result); + await client.invalidateQueries({ + queryKey: ["conversation-model-preferences", conversationId], + }); + }, + onError: async () => { + message.error(t("modelProfile.preferencesFailed")); + await client.invalidateQueries({ queryKey }); + }, + }); + const [, refreshDraft] = useState(0); const [rejected, setRejected] = useState>({}); - const saved = sessions[sessionKey]; const selectedId = controlledModelId ?? - saved?.modelId ?? - (models.some((m) => m.id === defaultModelId) + (persisted + ? selection.data?.selected_model_id?.toString() + : readModelSelectionDraft(sessionKey)) ?? + (models.some((model) => model.id === defaultModelId) ? defaultModelId : models[0]?.id); - const model = models.find((m) => m.id === selectedId); - const remembered = selectedId ? saved?.values[selectedId] : undefined; - const options = Array.isArray(model?.efforts) ? model.efforts : []; - const fallbackEffort = isReasoningEffort(model?.defaultEffort) - ? model.defaultEffort - : "auto"; - const effort = - remembered?.effort === "auto" || - options.some((e) => e.id === remembered?.effort) - ? (remembered?.effort ?? fallbackEffort) - : fallbackEffort; - const supported = Boolean( - model?.efforts || - model?.budgetTokens || - model?.reasoningCapability?.status === "supported" - ); - const enabled = - supported && (remembered?.enabled ?? model?.defaultDeepThinking ?? false); - const range = model?.budgetTokens; - const rawBudget = remembered?.budget ?? model?.defaultBudgetTokens ?? 0; - const budget = - rawBudget > 0 && range - ? Math.min(range.max, Math.max(range.min, rawBudget)) - : 0; - const activeModelId = model?.id; - const hasBudgetControl = Boolean(range); - const value: ModelState = { enabled, effort, budget }; - const parameterScope = JSON.stringify([sessionKey, selectedId]); - const samplingValue = - sampling[parameterScope] ?? DEFAULT_MODEL_PARAMETER_VALUES; + const model = models.find((item) => item.id === selectedId); + const scope = JSON.stringify([sessionKey, selectedId]); useEffect( () => subscribeRejectedSelection((id) => { if (String(id) === selectedId) - setRejected((current) => ({ ...current, [parameterScope]: true })); + setRejected((current) => ({ ...current, [scope]: true })); }), - [selectedId, parameterScope] + [selectedId, scope] ); const snapshot = useMemo( - () => - activeModelId - ? snapshotTurnModelControls(activeModelId, { - deep_thinking: enabled, - thinking_effort: hasBudgetControl ? "auto" : effort, - ...(hasBudgetControl ? { reasoning_budget_tokens: budget } : {}), - ...samplingValue, - }) - : undefined, - [activeModelId, enabled, effort, hasBudgetControl, budget, samplingValue] + () => (model ? { model_id: Number(model.id) } : undefined), + [model?.id] ); - const update = (patch: Partial) => { - if (!selectedId || !model) return; - setSessions((current) => ({ - ...current, - [sessionKey]: { - modelId: selectedId, - values: { - ...current[sessionKey]?.values, - [selectedId]: { ...value, ...patch }, - }, - }, - })); - }; return { models, + error: + (selectedId && !model) || rejected[scope] + ? t("modelProfile.modelUnavailable") + : persisted && selection.isError + ? t("modelProfile.preferencesFailed") + : undefined, selectedModelId: selectedId, onModelChange: (modelId: string) => { - if (!models.some((m) => m.id === modelId)) return; + if (!models.some((item) => item.id === modelId)) return; setRejected((current) => ({ ...current, [JSON.stringify([sessionKey, modelId])]: false, })); - setSessions((current) => ({ - ...current, - [sessionKey]: { - modelId, - values: { - ...current[sessionKey]?.values, - ...(selectedId && model ? { [selectedId]: value } : {}), - }, - }, - })); - }, - deepThinking: enabled, - onDeepThinkingChange: (next: boolean) => - update({ - enabled: next, - ...(next && !enabled - ? { effort: fallbackEffort, budget: model?.defaultBudgetTokens ?? 0 } - : {}), - }), - thinkingEffort: range ? ("auto" as const) : effort, - onThinkingEffortChange: (next: ReasoningEffort) => update({ effort: next }), - reasoningBudgetTokens: budget, - onReasoningBudgetTokensChange: (next: number) => update({ budget: next }), - canSend: Boolean(model) && !rejected[parameterScope], - modelParameterSettings: { - scopeKey: parameterScope, - value: samplingValue, - defaultValue: DEFAULT_MODEL_PARAMETER_VALUES, - onChange: (next: ModelParameterValue) => - setSampling((current) => ({ ...current, [parameterScope]: next })), + if (persisted) { + if (selection.data && !save.isPending) save.mutate(modelId); + } else { + selectModelDraft(sessionKey, modelId); + refreshDraft((version) => version + 1); + } }, + canSend: + Boolean(model) && + !rejected[scope] && + !save.isPending && + (!persisted || (!selection.isError && !!selection.data)), snapshot, }; } diff --git a/frontend/features/workbench/WorkbenchPage.tsx b/frontend/features/workbench/WorkbenchPage.tsx index 2b736fbcc5..52bdef2a6b 100644 --- a/frontend/features/workbench/WorkbenchPage.tsx +++ b/frontend/features/workbench/WorkbenchPage.tsx @@ -6,7 +6,6 @@ import { ComposerAttachment, ComposerDictation, } from "@/app/newchat/assistant-ui/composer-tools"; -import { DEFAULT_MODEL_PARAMETER_VALUES } from "@/const/modelParameterSettings"; import { restoreKnowledgeDisplay } from "./knowledgeDisplay"; import { @@ -43,9 +42,7 @@ import { SidebarProvider } from "@/components/ui/sidebar"; import { message } from "antd"; import type { Agent } from "@/types/agentConfig"; import { useModelList } from "@/hooks/model/useModelList"; -import { withDefaultWorkbenchModel } from "./modelOptions"; import { WorkbenchLoadingBoundary } from "./components/WorkbenchLoadingBoundary"; -import { selectWorkbenchModel } from "@/features/model-controls/modelOptions"; import log from "@/lib/logger"; import { ConversationTitleGuard } from "@/lib/conversationTitle"; import { conversationService } from "@/services/conversationService"; @@ -130,11 +127,9 @@ const HomeContent: FC = () => { dispatchWorkbench, } = useWorkbenchSession(); const { availableLlmModels, isLoading: isLoadingModels } = useModelList(); - const effectiveWorkbenchConfig = useMemo( - () => withDefaultWorkbenchModel(workbenchState.config, availableLlmModels), - [workbenchState.config, availableLlmModels] - ); + const effectiveWorkbenchConfig = workbenchState.config; const { t, i18n } = useTranslation(); + const { hasPermission, user } = useAuthorizationContext(); const { resetFlow: resetNl2AgentFlow, @@ -146,11 +141,6 @@ const HomeContent: FC = () => { const [chatMode, setChatMode] = useState("execution"); const [skillPickerOpen, setSkillPickerOpen] = useState(false); const [agentPickerOpen, setAgentPickerOpen] = useState(false); - const [isSavingModel, setIsSavingModel] = useState(false); - const [modelSettingsRevision, setModelSettingsRevision] = useState(0); - const rememberedModelControls = useRef( - new Map() - ); const [creationAgentsByThread, setCreationAgentsByThread] = useState< Record >({}); @@ -160,7 +150,6 @@ const HomeContent: FC = () => { const [recoverableCreationThreads, setRecoverableCreationThreads] = useState< ReadonlySet >(new Set()); - const modelSavePending = useRef(false); const skillDraftRef = useRef(initialSkillCreationDraft); const [skillDraft, setSkillDraft] = useState(initialSkillCreationDraft); const [savedSkillName, setSavedSkillName] = useState(null); @@ -880,106 +869,6 @@ const HomeContent: FC = () => { ] ); - const handleModelChange = async (modelId: string) => { - if (modelSavePending.current) return; - const id = Number(modelId); - if (!Number.isInteger(id) || id <= 0) return; - if (workbenchState.config.model_id === id) return; - const scope = `${user?.tenantId ?? ""}:${user?.id ?? ""}:${activeThreadId ?? "new"}`; - if (effectiveWorkbenchConfig.model_id != null) { - rememberedModelControls.current.set( - `${scope}:${effectiveWorkbenchConfig.model_id}`, - { - deep_thinking: - effectiveWorkbenchConfig.generation_config.deep_thinking, - thinking_effort: - effectiveWorkbenchConfig.generation_config.thinking_effort, - reasoning_budget_tokens: - effectiveWorkbenchConfig.generation_config.reasoning_budget_tokens, - temperature: effectiveWorkbenchConfig.generation_config.temperature, - top_p: effectiveWorkbenchConfig.generation_config.top_p, - } - ); - } - const remembered = rememberedModelControls.current.get(`${scope}:${id}`); - const config = selectWorkbenchModel( - workbenchState.config, - availableLlmModels, - id, - remembered - ); - const conversationId = Number(activeConversationId); - const isExistingConversation = - Number.isInteger(conversationId) && conversationId > 0; - modelSavePending.current = true; - setIsSavingModel(true); - try { - if (isExistingConversation) { - await saveWorkbenchConfig(conversationId, config); - } else { - dispatchWorkbench({ - type: "restore", - config, - version: workbenchState.configVersion, - }); - } - } catch (error) { - message.error( - error instanceof Error ? error.message : "模型配置保存失败" - ); - } finally { - modelSavePending.current = false; - setIsSavingModel(false); - } - }; - - const handleThinkingChange = async ( - patch: Partial< - import("@/features/workbench").WorkbenchSessionConfig["generation_config"] - > - ) => { - if (modelSavePending.current) return; - const base = - patch.deep_thinking === true && - !effectiveWorkbenchConfig.generation_config.deep_thinking && - effectiveWorkbenchConfig.model_id != null - ? selectWorkbenchModel( - effectiveWorkbenchConfig, - availableLlmModels, - effectiveWorkbenchConfig.model_id - ) - : effectiveWorkbenchConfig; - const config = { - ...base, - generation_config: { - ...base.generation_config, - ...patch, - }, - }; - modelSavePending.current = true; - setIsSavingModel(true); - try { - if (Number(activeConversationId) > 0) { - await saveWorkbenchConfig(Number(activeConversationId), config); - } else { - dispatchWorkbench({ - type: "restore", - config, - version: workbenchState.configVersion, - }); - } - } catch (error) { - message.error( - error instanceof Error ? error.message : "思考配置保存失败" - ); - // Discard only the failed numeric edit; never display it as saved intent. - setModelSettingsRevision((current) => current + 1); - } finally { - modelSavePending.current = false; - setIsSavingModel(false); - } - }; - const shouldRestoreAgentRef = useRef(true); const previousActiveThreadIdRef = useRef(activeThreadId); @@ -1111,7 +1000,6 @@ const HomeContent: FC = () => { language: i18n.language?.startsWith("en") ? ("en" as const) : ("zh" as const), - modelId: effectiveWorkbenchConfig.model_id, draftSnapshot: { ...(buildSkillSavePayload(skillDraftRef.current) ?? {}), files: Object.entries(skillDraftRef.current.files).map( @@ -1390,7 +1278,6 @@ const HomeContent: FC = () => { pinStorageKey={ user ? getWorkbenchPinStorageKey(user.id, user.tenantId) : null } - showLegacySwitch={false} generatedTitles={generatedTitles} serverConversationIds={serverConversationIds} onRenameSuccess={handleConversationRenamed} @@ -1432,48 +1319,7 @@ const HomeContent: FC = () => { modelSelectionScope="tenant" fallbackAgentName={t("workbench.genericAgentName", "智能体工作台")} landingContent={} - selectedModelId={effectiveWorkbenchConfig.model_id?.toString()} - deepThinking={ - effectiveWorkbenchConfig.generation_config.deep_thinking - } - onDeepThinkingChange={(enabled) => - void handleThinkingChange({ deep_thinking: enabled }) - } - thinkingEffort={ - effectiveWorkbenchConfig.generation_config.thinking_effort - } - onThinkingEffortChange={(effort) => - void handleThinkingChange({ thinking_effort: effort }) - } showModelSelector={true} - reasoningBudgetTokens={ - effectiveWorkbenchConfig.generation_config - .reasoning_budget_tokens ?? 0 - } - onReasoningBudgetTokensChange={(budget) => - void handleThinkingChange({ - reasoning_budget_tokens: budget, - thinking_effort: "auto", - }) - } - modelParameterSettings={{ - scopeKey: `${user?.tenantId ?? ""}:${user?.id ?? ""}:${activeThreadId ?? "new"}:${effectiveWorkbenchConfig.model_id ?? "default"}:${modelSettingsRevision}`, - value: { - temperature: - effectiveWorkbenchConfig.generation_config.temperature ?? - undefined, - top_p: - effectiveWorkbenchConfig.generation_config.top_p ?? undefined, - }, - defaultValue: DEFAULT_MODEL_PARAMETER_VALUES, - disabled: isThreadRunning || isSavingModel, - onChange: (value) => - void handleThinkingChange({ - temperature: value.temperature, - top_p: value.top_p, - }), - }} - onModelChange={(id) => void handleModelChange(id)} onAgentSelected={handleAgentSelectedFromLanding} chatMode={chatMode} onChatModeChange={handleChatModeChange} @@ -1493,7 +1339,6 @@ const HomeContent: FC = () => { onRuntimeMetadataChange={handleRuntimeMetadataChange} readOnly={ !workbenchSendability.canSend || - isSavingModel || (workbenchState.config.mode === "agent_create" && isAgentCreationAwaitingDraft( activeThread?.remoteId, @@ -1507,14 +1352,13 @@ const HomeContent: FC = () => { nl2AgentComposerDisabled) } readOnlyReason={ - isSavingModel - ? "正在保存模型配置" - : !isCreating && effectiveWorkbenchConfig.model_id == null - ? isLoadingModels - ? "正在加载模型" - : "暂无可用模型" - : workbenchSendability.reason + !isCreating && availableLlmModels.length === 0 + ? isLoadingModels + ? "正在加载模型" + : "暂无可用模型" + : workbenchSendability.reason } + skillFiles={ workbenchState.config.mode === "skill_create" && Object.keys(skillDraft.files).length > 0 diff --git a/frontend/features/workbench/modelOptions.ts b/frontend/features/workbench/modelOptions.ts index a5644288bc..8db98b15b6 100644 --- a/frontend/features/workbench/modelOptions.ts +++ b/frontend/features/workbench/modelOptions.ts @@ -1,7 +1,4 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -export { - deriveModelOptions, - withDefaultWorkbenchModel, -} from "@/features/model-controls/modelOptions"; +export { deriveModelOptions } from "@/features/model-controls/modelOptions"; export type { ModelSelectionScope } from "@/features/model-controls/modelOptions"; diff --git a/frontend/features/workbench/runtimeEvents.ts b/frontend/features/workbench/runtimeEvents.ts index 9ed9f15735..15fc067b87 100644 --- a/frontend/features/workbench/runtimeEvents.ts +++ b/frontend/features/workbench/runtimeEvents.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + /** Accept only safe version confirmations, never arbitrary SSE config objects. */ export function notifyWorkbenchConfigResolved( content: unknown, @@ -7,7 +9,7 @@ export function notifyWorkbenchConfigResolved( const value = typeof content === "string" ? JSON.parse(content) : content; if ( value && - value.schema_version === 3 && + value.schema_version === 4 && Number.isSafeInteger(value.config_version) && value.config_version >= 0 ) { diff --git a/frontend/features/workbench/state.ts b/frontend/features/workbench/state.ts index b90c654373..d7322e23fd 100644 --- a/frontend/features/workbench/state.ts +++ b/frontend/features/workbench/state.ts @@ -42,9 +42,8 @@ export const initialWorkbenchState: WorkbenchState = { resolving: false, skillNames: {}, config: { - schema_version: 3, + schema_version: 4, mode: "generic_chat", - generation_config: { deep_thinking: false, thinking_effort: "low" }, agent_mounts: [], skill_mounts: [], }, @@ -77,8 +76,6 @@ export function workbenchReducer( config: { ...state.config, mode: action.mode, - model_id: - action.mode === "generic_chat" ? state.config.model_id : undefined, agent_mounts: [], skill_mounts: [], knowledge_scope: undefined, diff --git a/frontend/features/workbench/types.ts b/frontend/features/workbench/types.ts index d22c7c4b65..d8e489ec1f 100644 --- a/frontend/features/workbench/types.ts +++ b/frontend/features/workbench/types.ts @@ -1,6 +1,5 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import type { ReasoningEffort } from "@/types/modelConfig"; import type { ConversationKnowledgeScope } from "@/types/knowledgeScope"; export type WorkbenchMode = @@ -21,17 +20,9 @@ export interface WorkbenchAgentMount { } export interface WorkbenchSessionConfig { - schema_version: 3; + schema_version: 4; mode: WorkbenchMode; - model_id?: number; - generation_config: { - deep_thinking: boolean; - thinking_effort?: ReasoningEffort; - reasoning_budget_tokens?: number; - temperature?: number; - top_p?: number; - requested_output_tokens?: number; - }; + requested_output_tokens?: number; agent_mounts: WorkbenchAgentMount[]; skill_mounts: WorkbenchSkillMount[]; knowledge_scope?: ConversationKnowledgeScope; diff --git a/frontend/services/conversationService.ts b/frontend/services/conversationService.ts index e356a1c21f..9e4ba154d9 100644 --- a/frontend/services/conversationService.ts +++ b/frontend/services/conversationService.ts @@ -1,6 +1,5 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import type { RuntimeGenerationOverrides } from "@/features/model-controls/turnControls"; import { API_ENDPOINTS, ApiError } from "./api"; import { chatConfig } from "@/const/chatConfig"; @@ -1089,17 +1088,7 @@ export const conversationService = { }>; agent_id?: number; // Add agent_id parameter model_id?: number; // Optional model override - generation_config?: RuntimeGenerationOverrides; - reasoning_effort?: - | "auto" - | "none" - | "minimal" - | "low" - | "medium" - | "high" - | "xhigh" - | "max"; - reasoning_budget_tokens?: number; + model_parameter_preferences?: Record>; version_no?: number; // Optional version override is_debug?: boolean; // Add debug mode parameter is_resume?: boolean; // Add resume mode parameter for streaming recovery @@ -1166,27 +1155,9 @@ export const conversationService = { if (params.model_id !== undefined && params.model_id !== null) { requestParams.model_id = params.model_id; } - if (params.generation_config !== undefined) { - requestParams.generation_config = params.generation_config; - } - const budgetTokens = params.reasoning_budget_tokens; - const hasBudgetSelection = - typeof budgetTokens === "number" && - Number.isInteger(budgetTokens) && - budgetTokens >= 0; - if ( - !hasBudgetSelection && - params.reasoning_effort !== undefined && - params.reasoning_effort !== "auto" - ) { - requestParams.reasoning_effort = params.reasoning_effort; - } - if ( - hasBudgetSelection && - budgetTokens !== undefined && - budgetTokens > 0 - ) { - requestParams.reasoning_budget_tokens = budgetTokens; + if (params.model_parameter_preferences !== undefined) { + requestParams.model_parameter_preferences = + params.model_parameter_preferences; } if (params.version_no !== undefined && params.version_no !== null) { requestParams.version_no = params.version_no; diff --git a/frontend/tests/component/agentChatPageDeepLink.test.tsx b/frontend/tests/component/agentChatPageDeepLink.test.tsx index 27d99b2e9c..893d903fa9 100644 --- a/frontend/tests/component/agentChatPageDeepLink.test.tsx +++ b/frontend/tests/component/agentChatPageDeepLink.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import React from "react"; @@ -152,7 +153,15 @@ describe("newchat Agent deep-link page wiring", () => { switchToNewThread.mockImplementation( () => new Promise(() => undefined) ); - render(); + render( + + + + ); expect( await screen.findByText("Agent chat: Fixed Agent") @@ -163,7 +172,15 @@ describe("newchat Agent deep-link page wiring", () => { it("consumes the deep link once and keeps manual selection working", async () => { switchToNewThread.mockResolvedValue(undefined); const user = userEvent.setup(); - render(); + render( + + + + ); expect( await screen.findByText("Agent chat: Fixed Agent") @@ -207,7 +224,15 @@ describe("newchat Agent deep-link page wiring", () => { }) ); const user = userEvent.setup(); - render(); + render( + + + + ); await screen.findByText("Agent chat: Fixed Agent"); await user.click(screen.getByRole("button", { name: "back to list" })); diff --git a/frontend/tests/component/agentChatRefreshRestore.test.tsx b/frontend/tests/component/agentChatRefreshRestore.test.tsx index a88b80af23..f6b6f04a60 100644 --- a/frontend/tests/component/agentChatRefreshRestore.test.tsx +++ b/frontend/tests/component/agentChatRefreshRestore.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import React from "react"; @@ -170,7 +171,15 @@ describe("newchat refresh restore wiring", () => { it("passes the last viewed conversation as the runtime's initial thread", () => { window.sessionStorage.setItem(CONVERSATION_KEY, "77"); - render(); + render( + + + + ); expect(capturedRuntimeOptions?.threadId).toBeUndefined(); expect(capturedRuntimeOptions?.initialThreadId).toBe("77"); @@ -179,7 +188,15 @@ describe("newchat refresh restore wiring", () => { it("starts on the landing when the user returns via navigation, not refresh", () => { // First visit: the stored conversation is restored, like a refresh. window.sessionStorage.setItem(CONVERSATION_KEY, "77"); - const { unmount } = render(); + const { unmount } = render( + + + + ); expect(capturedRuntimeOptions?.initialThreadId).toBe("77"); // Leaving the chat route (e.g. to the agent workbench) runs the unmount @@ -190,14 +207,30 @@ describe("newchat refresh restore wiring", () => { // Returning through the menu therefore starts on the landing page. // (The mocked runtime hook overwrites the captured options on every // render, so they can be asserted directly after the second render.) - render(); + render( + + + + ); expect(capturedRuntimeOptions?.initialThreadId).toBeUndefined(); expect(screen.getByText("Agent list")).toBeInTheDocument(); }); it("reopens a blank conversation of the stored agent when no conversation was saved", async () => { window.sessionStorage.setItem(AGENT_KEY, "41"); - render(); + render( + + + + ); expect(capturedRuntimeOptions?.initialThreadId).toBeUndefined(); expect( @@ -209,7 +242,15 @@ describe("newchat refresh restore wiring", () => { window.sessionStorage.setItem(CONVERSATION_KEY, "77"); window.sessionStorage.setItem(AGENT_KEY, "41"); window.history.replaceState({}, "", "/zh/newchat?agent_id=41"); - render(); + render( + + + + ); expect(capturedRuntimeOptions?.initialThreadId).toBeUndefined(); // The deep link still selects the agent. @@ -219,7 +260,15 @@ describe("newchat refresh restore wiring", () => { }); it("starts on the landing page when nothing was stored", () => { - render(); + render( + + + + ); expect(capturedRuntimeOptions?.initialThreadId).toBeUndefined(); expect(screen.getByText("Agent list")).toBeInTheDocument(); @@ -231,7 +280,15 @@ describe("newchat refresh restore wiring", () => { // marker must survive this window for a second refresh to restore. usePublishedAgentList.mockReturnValue({ isLoading: true, agents: [] }); try { - render(); + render( + + + + ); expect(window.sessionStorage.getItem(CONVERSATION_KEY)).toBe("77"); } finally { @@ -252,7 +309,15 @@ describe("newchat refresh restore wiring", () => { }, thread: { isLoading: false, isRunning: false }, }; - render(); + render( + + + + ); await user.click(screen.getByText("select agent")); await screen.findByText("Agent chat: Fixed Agent"); @@ -267,7 +332,15 @@ describe("newchat refresh restore wiring", () => { it("stores the agent id while the user is in an unsent blank conversation", async () => { const user = userEvent.setup(); - render(); + render( + + + + ); await user.click(screen.getByText("select agent")); await screen.findByText("Agent chat: Fixed Agent"); diff --git a/frontend/tests/component/modelMigrationBadge.test.tsx b/frontend/tests/component/modelMigrationBadge.test.tsx new file mode 100644 index 0000000000..b7d7257517 --- /dev/null +++ b/frontend/tests/component/modelMigrationBadge.test.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { render, screen } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { TooltipProvider } from "../../components/ui/tooltip"; +import { ModelMigrationBadge } from "../../app/[locale]/models/components/model/ModelMigrationBadge"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +it.each([ + ["migrated" as const, true, "pending"], + ["migrated" as const, false, "reviewed"], + ["failed" as const, undefined, "failed"], +])( + "shows %s review=%s as a reminder without a disabled interaction", + (migrationStatus, migrationReviewRequired, label) => { + render( + + + + ); + const badge = screen.getByText(`modelMigration.${label}`); + expect(badge).toBeVisible(); + expect(badge).not.toHaveAttribute("disabled"); + } +); + +it("does not mark outlets without a migration source", () => { + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); +}); diff --git a/frontend/tests/component/modelMigrationReview.test.tsx b/frontend/tests/component/modelMigrationReview.test.tsx new file mode 100644 index 0000000000..9ed1a4006e --- /dev/null +++ b/frontend/tests/component/modelMigrationReview.test.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { App } from "antd"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { ModelMigrationReviewButton } from "../../app/[locale]/models/components/model/ModelMigrationReviewButton"; +import type { ModelOption } from "../../types/modelConfig"; + +const mocks = vi.hoisted(() => ({ + role: "ADMIN", + getModelOutlet: vi.fn(), + confirmModelOutletMigration: vi.fn(), +})); +vi.mock("../../components/providers/AuthorizationProvider", () => ({ + useAuthorizationContext: () => ({ user: { role: mocks.role } }), +})); +vi.mock("../../services/modelProfileService", () => ({ + getModelOutlet: mocks.getModelOutlet, + confirmModelOutletMigration: mocks.confirmModelOutletMigration, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +const model: ModelOption = { + id: 42, + name: "exact", + type: "llm", + maxTokens: 4096, + source: "OpenAI-API-Compatible", + apiKey: "", + apiUrl: "https://models.invalid/v1", + displayName: "chosen", + configRevision: 6, + migrationStatus: "migrated", + migrationReviewRequired: true, +}; +function setup(value = model) { + const onReviewed = vi.fn(); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const rendered = render( + + + + + + ); + return { ...rendered, onReviewed }; +} +beforeEach(() => { + vi.clearAllMocks(); + mocks.role = "ADMIN"; + mocks.confirmModelOutletMigration.mockReset().mockResolvedValue(undefined); + mocks.getModelOutlet.mockReset().mockResolvedValue({ + model_id: 42, + config_revision: 7, + has_credential: true, + profile: { + identity: { request_model_id: "exact" }, + connection: { base_url: "https://models.invalid/v1" }, + capabilities: { + context: { combined_window: 200000 }, + reasoning: { status: "unsupported" }, + tools: { status: "unsupported" }, + }, + parameter_values: { temperature: 0 }, + extra_api_parameters: [], + }, + }); +}); +it("only permits administrators to open a review", () => { + mocks.role = "DEV"; + const { container } = setup(); + expect(container.textContent).toBe(""); + expect(mocks.getModelOutlet).not.toHaveBeenCalled(); +}); +it("confirms the displayed fresh revision and does not expose credential contents", async () => { + const { onReviewed } = setup(); + fireEvent.click( + screen.getByRole("button", { name: "modelMigration.review" }) + ); + await screen.findByText("modelMigration.credentialPresent"); + fireEvent.click( + screen.getByRole("button", { name: "modelMigration.confirm" }) + ); + await waitFor(() => expect(onReviewed).toHaveBeenCalledOnce()); + expect(mocks.confirmModelOutletMigration).toHaveBeenCalledExactlyOnceWith( + 42, + 7 + ); +}); +it("keeps a conflicting review open and asks the administrator to review again", async () => { + mocks.confirmModelOutletMigration.mockRejectedValue( + new Error("MODEL_OUTLET_VERSION_CONFLICT") + ); + const { onReviewed } = setup(); + fireEvent.click( + screen.getByRole("button", { name: "modelMigration.review" }) + ); + await screen.findByText("modelMigration.credentialPresent"); + fireEvent.click( + screen.getByRole("button", { name: "modelMigration.confirm" }) + ); + await screen.findByText("modelMigration.confirmFailed"); + expect(onReviewed).not.toHaveBeenCalled(); + expect( + await screen.findByRole("button", { name: "modelMigration.confirm" }) + ).toBeVisible(); +}); +it("shows migration failures without loading or confirming a non-existent snapshot", async () => { + setup({ + ...model, + migrationStatus: "failed", + migrationDiagnostic: { category: "LEGACY_OUTLET_INVALID" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "modelMigration.review" }) + ); + await screen.findByText("LEGACY_OUTLET_INVALID"); + expect(mocks.getModelOutlet).not.toHaveBeenCalled(); + expect( + screen.queryByRole("button", { name: "modelMigration.confirm" }) + ).toBeNull(); +}); diff --git a/frontend/tests/component/modelParameterLayers.test.tsx b/frontend/tests/component/modelParameterLayers.test.tsx new file mode 100644 index 0000000000..081d21a5cd --- /dev/null +++ b/frontend/tests/component/modelParameterLayers.test.tsx @@ -0,0 +1,387 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { App } from "antd"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { AgentModelParameters } from "../../components/model/AgentModelParameters"; +import { ConversationModelParameters } from "../../components/model/ConversationModelParameters"; +import { + agentParameterValues, + withAgentParameterValues, +} from "../../lib/modelParameterLayers"; + +const api = vi.hoisted(() => ({ + getModelOutlet: vi.fn(), + getConversationModelPreferences: vi.fn(), + patchConversationModelPreferences: vi.fn(), +})); +vi.mock("../../services/modelProfileService", () => api); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +const rule = (key: string) => ({ + key, + visible: true, + editable: true, + required: false, + allowed_scopes: ["outlet", "conversation"], + schema: { type: "number" }, +}); +function setup(child: React.ReactNode) { + return render( + + {child} + + ); +} +beforeEach(() => { + Object.values(api).forEach((fn) => fn.mockReset()); + api.getModelOutlet.mockResolvedValue({ + config_revision: 1, + profile: { + parameter_values: { temperature: 0.8, top_k: 20 }, + parameter_rules: [rule("temperature"), rule("top_k"), rule("headers")], + capabilities: { context: { combined_window: 200000 } }, + }, + }); + api.getConversationModelPreferences.mockResolvedValue({ + version: 1, + parameter_values: {}, + defaults: { temperature: 0.8 }, + field_specs: [rule("temperature")], + issues: [], + }); + api.patchConversationModelPreferences.mockResolvedValue({}); +}); + +it("keeps false and zero explicit, stores canonical parameter_values and preserves legacy custom values", () => { + expect( + agentParameterValues({ + temperature: 0, + extra_params: { enable_thinking: false, top_k: 0 }, + }) + ).toEqual({ temperature: 0, enable_thinking: false, top_k: 0 }); + expect( + withAgentParameterValues( + { + temperature: 0.4, + extra_params: { top_k: 5, __custom__: { flag: false } }, + }, + { top_k: 0 } + ) + ).toEqual({ + parameter_values: { top_k: 0 }, + custom_overrides: { flag: false }, + }); +}); + +it("Agent scope excludes context editing and preserves historical context while saving explicit parameters", async () => { + const apply = vi.fn(); + setup( + + ); + const input = await screen.findByRole("spinbutton", { name: "top_k" }); + expect(input).toHaveValue("20"); + expect( + screen.queryByRole("spinbutton", { name: "headers" }) + ).not.toBeInTheDocument(); + fireEvent.change(input, { target: { value: "0" } }); + expect( + screen.queryByRole("spinbutton", { name: "context_window_tokens" }) + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => + expect(apply).toHaveBeenCalledWith({ + parameter_values: { top_k: 0 }, + context_window_tokens: 64000, + }) + ); +}); + +it("clearing an Agent override removes the field instead of copying the outlet default", async () => { + const apply = vi.fn(); + setup( + + ); + await screen.findByRole("spinbutton", { name: "temperature" }); + fireEvent.click( + screen.getByRole("button", { name: "modelProfile.useOutletDefault" }) + ); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => + expect(apply).toHaveBeenCalledWith({ parameter_values: {} }) + ); +}); + +it("session displays Agent values above outlet defaults but saves only the explicit session change", async () => { + setup( + + ); + const advanced = await screen.findByRole("button", { + name: "workbench.controls.advanced", + }); + await waitFor(() => expect(advanced).toBeEnabled()); + fireEvent.click(advanced); + const input = await screen.findByRole("spinbutton", { + name: "workbench.controls.temperature", + }); + expect(input).toHaveValue(0.4); + fireEvent.change(input, { target: { value: "0" } }); + fireEvent.blur(input); + await waitFor(() => + expect(api.patchConversationModelPreferences).toHaveBeenCalledWith(11, 42, { + expected_version: 1, + parameter_values: { temperature: 0 }, + delete_parameters: [], + }) + ); +}); + +it("failed outlet loading cannot apply an Agent configuration", async () => { + api.getModelOutlet.mockRejectedValue(new Error("offline")); + const apply = vi.fn(); + setup( + + ); + await screen.findByText("modelProfile.loadFailed"); + expect( + screen.queryByRole("button", { name: "common.confirm" }) + ).not.toBeInTheDocument(); + expect(apply).not.toHaveBeenCalled(); +}); + +it("reads current Agent parameters and writes current format without copying defaults", () => { + expect( + agentParameterValues({ + parameter_values: { temperature: 0, enable_thinking: false }, + }) + ).toEqual({ temperature: 0, enable_thinking: false }); + expect( + withAgentParameterValues( + { parameter_values: { top_p: 0.8 }, context_window_tokens: 4096 }, + {} + ) + ).toEqual({ parameter_values: {}, context_window_tokens: 4096 }); +}); + +it("Agent thinking hides degree while off and prefills half the output limit on enable", async () => { + const apply = vi.fn(); + api.getModelOutlet.mockResolvedValue({ + config_revision: 1, + profile: { + parameter_values: { enable_thinking: false }, + parameter_rules: [ + { ...rule("enable_thinking"), schema: { type: "boolean" } }, + { + ...rule("reasoning_budget_tokens"), + schema: { type: "integer", minimum: 1 }, + condition: { key: "enable_thinking", op: "eq", value: true }, + }, + ], + capabilities: { context: { combined_window: 8192, output_limit: 1024 } }, + }, + }); + setup( + + ); + const toggle = await screen.findByRole("switch"); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + fireEvent.click(toggle); + expect( + await screen.findByRole("combobox", { name: "modelProfile.thinkingMode" }) + ).toBeInTheDocument(); + expect( + await screen.findByRole("spinbutton", { + name: "modelProfile.thinkingDegree", + }) + ).toHaveValue("512"); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => + expect(apply).toHaveBeenCalledWith({ + parameter_values: { enable_thinking: true, reasoning_budget_tokens: 512 }, + }) + ); +}); + +it("Agent toggle-only outlet never invents an effort or budget parameter", async () => { + const apply = vi.fn(); + api.getModelOutlet.mockResolvedValue({ + config_revision: 1, + profile: { + parameter_values: { enable_thinking: false }, + parameter_rules: [ + { ...rule("enable_thinking"), schema: { type: "boolean" } }, + ], + capabilities: { context: { combined_window: 8192, output_limit: 1024 } }, + }, + }); + setup( + + ); + fireEvent.click(await screen.findByRole("switch")); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => + expect(apply).toHaveBeenCalledWith({ + parameter_values: { enable_thinking: true }, + }) + ); +}); + +it.each([ + { + key: "reasoning_effort", + schema: { type: "string", enum: ["low", "medium", "high"] }, + expected: "low", + }, + { + key: "reasoning_budget_tokens", + schema: { type: "integer", minimum: 1 }, + expected: 512, + }, +])( + "session enabling thinking prefills $key through the shared policy", + async ({ key, schema, expected }) => { + api.getConversationModelPreferences.mockResolvedValue({ + version: 1, + parameter_values: {}, + defaults: { enable_thinking: false }, + output_limit: 1024, + field_specs: [ + { ...rule("enable_thinking"), schema: { type: "boolean" } }, + { + ...rule(key), + schema, + condition: { key: "enable_thinking", op: "eq", value: true }, + }, + ], + issues: [], + }); + setup(); + const toggle = await screen.findByRole("switch"); + await waitFor(() => expect(toggle).toBeEnabled()); + fireEvent.click(toggle); + await waitFor(() => + expect(api.patchConversationModelPreferences).toHaveBeenCalledWith( + 11, + 42, + { + expected_version: 1, + parameter_values: { enable_thinking: true, [key]: expected }, + delete_parameters: [], + } + ) + ); + } +); + +it("Agent always-on reasoning still exposes its declared effort without inventing a toggle", async () => { + api.getModelOutlet.mockResolvedValue({ + config_revision: 1, + profile: { + parameter_values: { enable_thinking: true, reasoning_effort: "low" }, + parameter_rules: [ + { + ...rule("reasoning_effort"), + schema: { type: "string", enum: ["low", "high"] }, + }, + ], + capabilities: { context: { combined_window: 8192, output_limit: 1024 } }, + }, + }); + setup( + + ); + expect( + await screen.findByRole("combobox", { name: "modelProfile.thinkingDegree" }) + ).toBeEnabled(); + expect(screen.getByRole("switch")).toBeChecked(); + expect(screen.getByRole("switch")).toBeDisabled(); +}); + +it("restores inherited Agent thinking by clearing only the thinking overrides", async () => { + const apply = vi.fn(); + api.getModelOutlet.mockResolvedValue({ + config_revision: 1, + profile: { + parameter_values: { enable_thinking: false }, + parameter_rules: [ + { ...rule("enable_thinking"), schema: { type: "boolean" } }, + { + ...rule("reasoning_budget_tokens"), + schema: { type: "integer", minimum: 1 }, + condition: { key: "enable_thinking", op: "eq", value: true }, + }, + ], + capabilities: { context: { combined_window: 8192, output_limit: 1024 } }, + }, + }); + setup( + + ); + fireEvent.click( + await screen.findByRole("button", { name: "modelProfile.useOutletDefault" }) + ); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + expect(screen.getByRole("switch")).not.toBeChecked(); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => + expect(apply).toHaveBeenCalledWith({ parameter_values: {} }) + ); +}); diff --git a/frontend/tests/component/modelSelectorPreferences.test.tsx b/frontend/tests/component/modelSelectorPreferences.test.tsx new file mode 100644 index 0000000000..7d7189b7d6 --- /dev/null +++ b/frontend/tests/component/modelSelectorPreferences.test.tsx @@ -0,0 +1,314 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { App } from "antd"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { readModelDraft } from "../../lib/conversationModelDrafts"; +import { ModelSelector } from "../../app/[locale]/newchat/ui/model-selector"; +import type { + ConversationModelPreferenceView, + ModelParameterRule, +} from "../../types/modelProfile"; + +const api = vi.hoisted(() => ({ + getModelOutlet: vi.fn(), + getConversationModelPreferences: vi.fn(), + patchConversationModelPreferences: vi.fn(), +})); +vi.mock("../../services/modelProfileService", () => api); +vi.mock("@assistant-ui/react", () => ({ + useAui: () => ({ modelContext: () => ({ register: () => () => undefined }) }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +const numeric = (key: string): ModelParameterRule => ({ + key, + visible: true, + editable: true, + required: false, + ui_group: "sampling", + allowed_scopes: ["conversation"], + schema: { + type: "number", + minimum: 0, + maximum: key === "temperature" ? 2 : 1, + }, +}); +let saved: ConversationModelPreferenceView; +beforeEach(() => { + vi.clearAllMocks(); + saved = { + version: 3, + config_revision: 7, + parameter_values: {}, + defaults: { temperature: 0.7, top_p: 1, enable_thinking: false }, + issues: [], + field_specs: [ + numeric("temperature"), + numeric("top_p"), + { + key: "enable_thinking", + required: false, + ui_group: "thinking", + visible: true, + editable: true, + allowed_scopes: ["conversation"], + schema: { type: "boolean" }, + }, + ], + }; + api.getModelOutlet.mockImplementation(async () => ({ + config_revision: 7, + profile: { parameter_values: saved.defaults }, + field_specs: saved.field_specs, + })); + api.getConversationModelPreferences.mockImplementation(async () => + structuredClone(saved) + ); + api.patchConversationModelPreferences.mockImplementation( + async (_cid, _mid, patch) => { + saved.version++; + saved.parameter_values = { + ...saved.parameter_values, + ...patch.parameter_values, + }; + patch.delete_parameters.forEach( + (key: string) => delete saved.parameter_values[key] + ); + } + ); +}); +function setup(conversationId: number | undefined) { + return render( + + + + + + ); +} +const temperatureName = "workbench.controls.temperature"; +const advancedName = "workbench.controls.advanced"; +async function openAdvanced() { + if (!screen.queryByRole("button", { name: advancedName })) + fireEvent.click(screen.getByRole("combobox")); + const advanced = await screen.findByRole("button", { name: advancedName }); + await waitFor(() => expect(advanced).toBeEnabled()); + fireEvent.click(advanced); + return screen.findByRole("spinbutton", { name: temperatureName }); +} +async function changeSampling(name: string, value: string) { + const field = await screen.findByRole("spinbutton", { name }); + await waitFor(() => expect(field).toBeEnabled()); + fireEvent.change(field, { target: { value } }); + fireEvent.blur(field); +} +it("UI-SESSION-15 reuses upstream sliders, persists native values and resets by deletion", async () => { + setup(11); + expect( + screen.queryByRole("button", { name: "modelProfile.parameters" }) + ).not.toBeInTheDocument(); + const thinking = screen.getByRole("switch", { + name: "chat.modelSelector.deepThinking", + }); + await waitFor(() => expect(thinking).toBeEnabled()); + fireEvent.click(thinking); + await waitFor(() => expect(thinking).toHaveAttribute("aria-checked", "true")); + expect(await openAdvanced()).toHaveValue(0.5); + expect(screen.getAllByRole("slider")).toHaveLength(2); + await changeSampling(temperatureName, "0.31"); + await waitFor(() => expect(saved.parameter_values.temperature).toBe(0.31)); + await changeSampling("Top P", "0.72"); + await waitFor(() => expect(saved.parameter_values.top_p).toBe(0.72)); + expect(api.patchConversationModelPreferences).toHaveBeenLastCalledWith( + 11, + 42, + { + expected_version: 5, + parameter_values: { temperature: 0.31, top_p: 0.72 }, + delete_parameters: [], + } + ); + const panel = await screen.findByRole("region", { name: advancedName }); + fireEvent.keyDown(panel, { key: "Escape" }); + expect(await openAdvanced()).toHaveValue(0.31); + expect(screen.getByRole("spinbutton", { name: "Top P" })).toHaveValue(0.72); + await waitFor(() => + expect( + screen.getByRole("button", { name: "workbench.controls.restore" }) + ).toBeEnabled() + ); + fireEvent.click( + screen.getByRole("button", { name: "workbench.controls.restore" }) + ); + await waitFor(() => + expect(saved.parameter_values).toEqual({ enable_thinking: true }) + ); + expect(api.patchConversationModelPreferences).toHaveBeenLastCalledWith( + 11, + 42, + { + expected_version: 6, + parameter_values: {}, + delete_parameters: ["temperature", "top_p"], + } + ); +}); +it("UI-SESSION-15 edits unsent conversation drafts without creating server preferences", async () => { + setup(undefined); + const thinking = screen.getByRole("switch", { + name: "chat.modelSelector.deepThinking", + }); + await waitFor(() => expect(thinking).toBeEnabled()); + fireEvent.click(thinking); + await waitFor(() => expect(thinking).toHaveAttribute("aria-checked", "true")); + expect(await openAdvanced()).toHaveValue(0.5); + await changeSampling(temperatureName, "0.24"); + await waitFor(() => + expect(readModelDraft("fresh-thread", 42)).toEqual({ + enable_thinking: true, + temperature: 0.24, + }) + ); + fireEvent.keyDown(screen.getByRole("region", { name: advancedName }), { + key: "Escape", + }); + expect(await openAdvanced()).toHaveValue(0.24); + expect(readModelDraft("another-thread", 42)).toEqual({}); + expect(readModelDraft("fresh-thread", 43)).toEqual({}); + expect(api.getConversationModelPreferences).not.toHaveBeenCalled(); + expect(api.patchConversationModelPreferences).not.toHaveBeenCalled(); +}); +it("UI-SESSION-15 keeps a failed quick save at the persisted state", async () => { + api.patchConversationModelPreferences.mockRejectedValue(new Error("stale")); + setup(11); + const thinking = await screen.findByRole("switch", { + name: "chat.modelSelector.deepThinking", + }); + await waitFor(() => expect(thinking).toBeEnabled()); + fireEvent.click(thinking); + await screen.findByText("modelProfile.preferencesFailed"); + expect(thinking).toHaveAttribute("aria-checked", "false"); + expect(api.patchConversationModelPreferences).toHaveBeenCalledTimes(1); +}); + +it("UI-SESSION-15 resets an effort by deletion and clears inactive reasoning when switched off", async () => { + saved.defaults.enable_thinking = true; + saved.defaults.reasoning_effort = "low"; + saved.parameter_values = { reasoning_effort: "high" }; + saved.field_specs.push({ + key: "reasoning_effort", + visible: true, + editable: true, + required: false, + ui_group: "thinking", + allowed_scopes: ["conversation"], + schema: { type: "string", enum: ["low", "high"] }, + }); + setup(11); + const reset = await screen.findByRole("button", { + name: "modelProfile.useInheritedDefault", + }); + fireEvent.click(reset); + await waitFor(() => + expect(api.patchConversationModelPreferences).toHaveBeenLastCalledWith( + 11, + 42, + { + expected_version: 3, + parameter_values: {}, + delete_parameters: ["reasoning_effort"], + } + ) + ); + await waitFor(() => + expect( + screen.getByRole("switch", { name: "chat.modelSelector.deepThinking" }) + ).toBeEnabled() + ); + fireEvent.click( + screen.getByRole("switch", { name: "chat.modelSelector.deepThinking" }) + ); + await waitFor(() => + expect(api.patchConversationModelPreferences).toHaveBeenLastCalledWith( + 11, + 42, + { + expected_version: 4, + parameter_values: { enable_thinking: false }, + delete_parameters: ["reasoning_effort"], + } + ) + ); +}); + +it("retains profile sampling bounds in the shared upstream control", async () => { + saved.field_specs[0].schema = { type: "number", minimum: 0.1, maximum: 0.6 }; + setup(11); + const field = await openAdvanced(); + expect(field).toHaveAttribute("min", "0.1"); + expect(field).toHaveAttribute("max", "0.6"); + fireEvent.change(field, { target: { value: "0.7" } }); + fireEvent.blur(field); + expect(field).toHaveAttribute("aria-invalid", "true"); + expect(api.patchConversationModelPreferences).not.toHaveBeenCalled(); + fireEvent.change(field, { target: { value: "0.6" } }); + fireEvent.blur(field); + await waitFor(() => expect(saved.parameter_values.temperature).toBe(0.6)); +}); +it("keeps a fixed sampling field read-only while other fields remain editable", async () => { + saved.field_specs[0].editable = false; + setup(11); + expect(await openAdvanced()).toBeDisabled(); + expect(screen.getByRole("spinbutton", { name: "Top P" })).toBeEnabled(); + expect(api.patchConversationModelPreferences).not.toHaveBeenCalled(); +}); + +it("SU-AC-03 keeps sampling inputs mounted while saved preferences refresh", async () => { + setup(11); + await openAdvanced(); + const topP = screen.getByRole("spinbutton", { name: "Top P" }); + let release: ((view: ConversationModelPreferenceView) => void) | undefined; + api.getConversationModelPreferences.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }) + ); + await changeSampling(temperatureName, "0.31"); + await waitFor(() => expect(release).toBeDefined()); + expect(screen.getByRole("spinbutton", { name: "Top P" })).toBe(topP); + expect(topP).toBeDisabled(); + await act(async () => { + release!(structuredClone(saved)); + }); + await changeSampling("Top P", "0.72"); + await waitFor(() => + expect(saved.parameter_values).toEqual({ temperature: 0.31, top_p: 0.72 }) + ); +}); diff --git a/frontend/tests/component/modelSelectorProfileContext.test.tsx b/frontend/tests/component/modelSelectorProfileContext.test.tsx new file mode 100644 index 0000000000..2eea6deb24 --- /dev/null +++ b/frontend/tests/component/modelSelectorProfileContext.test.tsx @@ -0,0 +1,31 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { render, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { ModelSelector } from "../../app/[locale]/newchat/ui/model-selector"; +const context = vi.hoisted(() => ({ + register: vi.fn((_entry: unknown) => vi.fn()), +})); +vi.mock("@assistant-ui/react", () => ({ + useAui: () => ({ modelContext: () => context }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +beforeEach(() => context.register.mockClear()); +it.each([1, undefined])( + "SU-AC-07 model context carries identity only, never a parallel parameter snapshot (schema=%s)", + async (schema) => { + render( + + ); + await waitFor(() => expect(context.register).toHaveBeenCalled()); + const entry = context.register.mock.calls.at(-1)![0] as { + getModelContext: () => unknown; + }; + expect(entry.getModelContext()).toEqual({ config: { modelName: "42" } }); + } +); diff --git a/frontend/tests/workbench/AgentDebugModelControls.test.ts b/frontend/tests/workbench/AgentDebugModelControls.test.ts index a6ab2fd4ea..05b88c196c 100644 --- a/frontend/tests/workbench/AgentDebugModelControls.test.ts +++ b/frontend/tests/workbench/AgentDebugModelControls.test.ts @@ -4,6 +4,11 @@ import { beforeEach, expect, it, vi } from "vitest"; import type { ChatModelRunOptions } from "@assistant-ui/react"; import { remoteChatModelAdapter } from "@/app/newchat/adapter/remote-chat-model-adapter"; +import { + patchModelDraft, + snapshotModelDrafts, +} from "@/lib/conversationModelDrafts"; + const service = vi.hoisted(() => ({ runAgent: vi.fn() })); vi.mock("@/services/conversationService", () => ({ conversationService: service, @@ -62,79 +67,57 @@ async function consume(input: ChatModelRunOptions) { } it.each([ - [{ deepThinking: false, reasoningEffort: "auto" }, { deep_thinking: false }], - [ - { deepThinking: true, reasoningEffort: "auto" }, - { deep_thinking: true, thinking_effort: "auto" }, - ], - [ - { deepThinking: true, reasoningEffort: "xhigh" }, - { deep_thinking: true, thinking_effort: "xhigh" }, - ], - [ - { - deepThinking: true, - reasoningEffort: "xhigh", - reasoningBudgetTokens: 4096, - }, - { deep_thinking: true, reasoning_budget_tokens: 4096 }, - ], - [ - { deepThinking: true, reasoningEffort: "auto", reasoningBudgetTokens: 0 }, - { deep_thinking: true, reasoning_budget_tokens: 0 }, - ], + { enable_thinking: false, temperature: 0, top_p: 0.8 }, + { + enable_thinking: true, + reasoning_effort: "low", + temperature: 0.4, + top_p: 1, + }, + { enable_thinking: true, reasoning_budget_tokens: 4096 }, ])( - "serializes explicit debug reasoning intent %j", - async (config, expected) => { - const input = debugOptions(config); - const previous = structuredClone({ - messages: input.messages, - context: input.context, - runConfig: input.runConfig, - }); + "sends debug parameters through the canonical draft map %j", + async (values) => { + const scope = `debug-${JSON.stringify(values)}`; + patchModelDraft(scope, 12, values, []); + const input = debugOptions({}); + Object.assign(input.runConfig!.custom!, { modelPreferenceScope: scope }); const body = await consume(input); - expect(body.generation_config).toEqual(expected); + expect(body.model_parameter_preferences).toEqual({ "12": values }); expect(body).toMatchObject({ model_id: 12, agent_id: 7, version_no: 3, is_debug: true, - history: [{ role: "assistant", content: "old" }], }); expect(body.conversation_id).toBeUndefined(); - expect({ - messages: input.messages, - context: input.context, - runConfig: input.runConfig, - }).toEqual(previous); + expect(body.generation_config).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + expect(snapshotModelDrafts(scope)).toEqual({ "12": values }); } ); -it("keeps omitted debug intent and existing sampling exclusion unchanged", async () => { - const body = await consume(debugOptions({ temperature: 0.4, top_p: 0.8 })); - expect(body.generation_config).toBeUndefined(); -}); - -it("does not add sampling or stale effort to the disabled generation config", async () => { +it("ignores obsolete ModelContext parameter snapshots", async () => { const body = await consume( debugOptions({ - deepThinking: false, - reasoningEffort: "xhigh", - temperature: 0, - top_p: 0, + deepThinking: true, + reasoningEffort: "high", + temperature: 0.4, + top_p: 0.8, }) ); - expect(body.generation_config).toEqual({ deep_thinking: false }); - // The compatibility field is retained; canonical disabled intent wins downstream. - expect(body.reasoning_effort).toBe("xhigh"); + expect(body.generation_config).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); }); -it("preserves the explicit disabled request when the backend rejects the run", async () => { +it("preserves canonical debug drafts when the backend rejects the run", async () => { + const scope = "debug-failed-run"; + patchModelDraft(scope, 12, { enable_thinking: false }, []); + const input = debugOptions({}); + Object.assign(input.runConfig!.custom!, { modelPreferenceScope: scope }); service.runAgent.mockRejectedValueOnce(new Error("offline")); - await expect(consume(debugOptions({ deepThinking: false }))).rejects.toThrow( - "offline" - ); - expect(service.runAgent.mock.calls[0][0].generation_config).toEqual({ - deep_thinking: false, + await expect(consume(input)).rejects.toThrow("offline"); + expect(snapshotModelDrafts(scope)).toEqual({ + "12": { enable_thinking: false }, }); }); diff --git a/frontend/tests/workbench/AgentModels.test.ts b/frontend/tests/workbench/AgentModels.test.ts index 982c2690af..6d9d686e33 100644 --- a/frontend/tests/workbench/AgentModels.test.ts +++ b/frontend/tests/workbench/AgentModels.test.ts @@ -5,52 +5,9 @@ import { initialWorkbenchState, workbenchReducer, } from "@/features/workbench/state"; -import { - deriveModelOptions, - withDefaultWorkbenchModel, -} from "@/features/workbench/modelOptions"; +import { deriveModelOptions } from "@/features/workbench/modelOptions"; import type { Agent } from "@/types/agentConfig"; import type { ModelOption } from "@/types/modelConfig"; -import { selectWorkbenchModel } from "@/features/model-controls/modelOptions"; - -it("switches models without leaking sampling overrides and retains output settings", () => { - const config = { - ...initialWorkbenchState.config, - model_id: 11, - generation_config: { - deep_thinking: true, - thinking_effort: "xhigh" as const, - temperature: 0.43, - top_p: 0.78, - requested_output_tokens: 2048, - }, - }; - const model: ModelOption = { - ...reasoningModelFixture, - id: 12, - name: "B", - enableThinking: false, - reasoningCapability: { - status: "supported", - control: "effort", - levels: ["low", "xhigh"], - source: "catalog", - }, - }; - const next = selectWorkbenchModel(config, [model], 12); - expect(next.generation_config).toMatchObject({ - deep_thinking: false, - temperature: undefined, - top_p: undefined, - requested_output_tokens: 2048, - }); - expect(next.generation_config.thinking_effort).toBe("auto"); - expect(config.model_id).toBe(11); - expect( - selectWorkbenchModel(next, [model], 11, config.generation_config) - .generation_config - ).toMatchObject({ temperature: 0.43, top_p: 0.78 }); -}); const reasoningAgentFixture: Agent = { id: "7", @@ -127,26 +84,8 @@ it("resolves stale defaults while retaining valid overrides and other Workbench const config = { ...initialWorkbenchState.config, model_id: 11, - generation_config: { - deep_thinking: true, - thinking_effort: "high" as const, - temperature: 0.7, - top_p: 0.81, - requested_output_tokens: 2000, - }, - }; - expect(withDefaultWorkbenchModel(config, [model]).generation_config).toEqual({ - ...config.generation_config, - thinking_effort: "auto", - }); - const valid = { - ...config, - generation_config: { - ...config.generation_config, - thinking_effort: "xhigh" as const, - }, + requested_output_tokens: 2000, }; - expect(withDefaultWorkbenchModel(valid, [model])).toBe(valid); const agent: Agent = { ...reasoningAgentFixture, id: "7", @@ -237,54 +176,14 @@ const preview = { knowledge: {}, }; -it("uses the first available tenant model as Workbench default without replacing an explicit choice", () => { - expect(initialWorkbenchState.config.generation_config.thinking_effort).toBe( - "low" - ); - const available = [{ id: 11 }, { id: 12 }]; - expect( - withDefaultWorkbenchModel(initialWorkbenchState.config, available).model_id - ).toBe(11); - const explicit = { ...initialWorkbenchState.config, model_id: 12 }; - expect(withDefaultWorkbenchModel(explicit, available).model_id).toBe(12); - expect(withDefaultWorkbenchModel(initialWorkbenchState.config, [])).toBe( - initialWorkbenchState.config - ); -}); -it("does not replace the Workbench model with the mounted Agent model", () => { +it("SU-AC-01 resource changes do not introduce a separate Workbench selection", () => { const next = workbenchReducer(initialWorkbenchState, { type: "resolve-agent-success", preview, modelIds: [8, 9], }); - expect(next.config.model_id).toBeUndefined(); -}); -it("preserves a tenant model across changes to mounted Agents", () => { - const state = { - ...initialWorkbenchState, - config: { ...initialWorkbenchState.config, model_id: 9 }, - }; - expect( - workbenchReducer(state, { - type: "resolve-agent-success", - preview, - modelIds: [8, 9], - }).config.model_id - ).toBe(9); - expect( - workbenchReducer(state, { - type: "resolve-agent-success", - preview, - modelIds: [3], - }).config.model_id - ).toBe(9); - expect( - workbenchReducer(state, { - type: "resolve-agent-success", - preview, - modelIds: [], - }).config.model_id - ).toBe(9); + expect(next.config).not.toHaveProperty("model_id"); + expect(next.config).not.toHaveProperty("generation_config"); }); it("lists every available tenant LLM for the generic workbench agent", () => { @@ -501,10 +400,6 @@ it("inherits model effort when Agent only overrides the toggle, and prefers an e expect(deriveModelOptions(agent, [model], "tenant")[0].defaultEffort).toBe( "medium" ); - expect( - withDefaultWorkbenchModel(initialWorkbenchState.config, [model]) - .generation_config - ).toMatchObject({ deep_thinking: true, thinking_effort: "medium" }); }); it("preserves automatic defaults when no persisted model effort exists", () => { @@ -523,17 +418,6 @@ it("preserves automatic defaults when no persisted model effort exists", () => { deriveModelOptions(reasoningAgentFixture, [model], "tenant")[0] .defaultEffort ).toBe("auto"); - expect( - withDefaultWorkbenchModel(initialWorkbenchState.config, [model]) - .generation_config.thinking_effort - ).toBe("auto"); - expect( - selectWorkbenchModel( - { ...initialWorkbenchState.config, model_id: 12 }, - [model], - 11 - ).generation_config.thinking_effort - ).toBe("auto"); }); it("keeps unconfigured reasoning automatic instead of substituting a capability default", () => { @@ -564,26 +448,8 @@ it("keeps unconfigured reasoning automatic instead of substituting a capability const config = { ...initialWorkbenchState.config, model_id: 11, - generation_config: { - deep_thinking: false, - thinking_effort: "xhigh" as const, - temperature: 0.43, - top_p: 0.78, - requested_output_tokens: 2048, - }, - }; - expect( - selectWorkbenchModel(config, [model], 11).generation_config - ).toMatchObject({ - thinking_effort: "auto", - temperature: 0.43, - top_p: 0.78, requested_output_tokens: 2048, - }); - expect( - withDefaultWorkbenchModel(initialWorkbenchState.config, [model]) - .generation_config.thinking_effort - ).toBe("auto"); + }; expect( deriveModelOptions( agent, diff --git a/frontend/tests/workbench/Composer.test.tsx b/frontend/tests/workbench/Composer.test.tsx index 6a5a9c659c..ccfd948d26 100644 --- a/frontend/tests/workbench/Composer.test.tsx +++ b/frontend/tests/workbench/Composer.test.tsx @@ -153,38 +153,19 @@ vi.mock("@/app/newchat/ui/model-selector", () => ({ }, })); -it("forwards every known effort through Composer without fabricating a default", () => { - const changed = vi.fn(); +it("SU-AC-07 delegates parameters to the shared selector without a second effort controller", () => { render( ); - const props = selectorProps.mock.lastCall?.[0] as { - effort?: string; - onEffortChange: (value: string) => void; - }; - expect(props.effort).toBeUndefined(); - for (const level of [ - "auto", - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", - ]) { - props.onEffortChange(level); - expect(changed).toHaveBeenLastCalledWith(level); - } - const count = changed.mock.calls.length; - props.onEffortChange("provider-invalid"); - expect(changed).toHaveBeenCalledTimes(count); + const props = selectorProps.mock.lastCall?.[0]; + expect(props).not.toHaveProperty("onEffortChange"); + expect(props).not.toHaveProperty("parameterSettings"); + expect(props).toHaveProperty("parameterSettingsScopeKey"); }); vi.mock("@/app/newchat/ui/attachment", () => ({ ComposerAttachments: () => null, @@ -992,3 +973,21 @@ it("ordinary chat scopes shared parameters to the active thread and locks them w expect(selectorProps.mock.lastCall?.[0].parameterSettingsDisabled).toBe(true); runtime.threads.mainThreadId = "fixture-thread"; }); + +it("UI-SESSION-15 delegates profile preferences to the selector without a second parameter button", () => { + render( + + ); + expect(selectorProps.mock.lastCall?.[0]).toMatchObject({ + conversationId: 11, + value: "42", + }); + expect( + screen.queryByRole("button", { name: "modelProfile.parameters" }) + ).not.toBeInTheDocument(); +}); diff --git a/frontend/tests/workbench/ConversationModelControls.test.tsx b/frontend/tests/workbench/ConversationModelControls.test.tsx index cc7846fb59..53ce604ffe 100644 --- a/frontend/tests/workbench/ConversationModelControls.test.tsx +++ b/frontend/tests/workbench/ConversationModelControls.test.tsx @@ -1,189 +1,205 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { App } from "antd"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; +const api = vi.hoisted(() => ({ + getConversationModelSelection: vi.fn(), + patchConversationModelSelection: vi.fn(), +})); +vi.mock("@/services/modelProfileService", () => api); +let selected = 2; +beforeEach(() => { + vi.resetAllMocks(); + selected = 2; + api.getConversationModelSelection.mockImplementation(async () => ({ + selected_model_id: selected, + version: 4, + })); + api.patchConversationModelSelection.mockImplementation( + async (_id, modelId) => ({ + selected_model_id: (selected = modelId), + version: 5, + }) + ); +}); +function Wrapper({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + ); + return ( + + {children} + + ); +} import { useConversationModelControls } from "@/features/model-controls/useConversationModelControls"; -import { snapshotTurnModelControls } from "@/features/model-controls/turnControls"; import { rejectModelSelection } from "@/features/model-controls/rejectedSelection"; import type { ModelOption } from "@/app/newchat/ui/model-selector"; const models: ModelOption[] = [ - { - id: "1", - name: "A", - defaultEffort: "low", - defaultDeepThinking: false, - efforts: ["auto", "low", "xhigh"].map((id) => ({ id, name: id })), - }, - { - id: "2", - name: "B", - defaultEffort: "xhigh", - defaultDeepThinking: true, - efforts: ["auto", "xhigh"].map((id) => ({ id, name: id })), - }, + { id: "1", name: "A", profileSchemaVersion: 1 }, + { id: "2", name: "B", profileSchemaVersion: 1 }, ]; -describe("shared conversation model controls", () => { - it("re-enabling thinking restores current configured effort and budget, not temporary values", () => { - const { result, rerender } = renderHook( - ({ options }) => useConversationModelControls(options, "reset"), - { initialProps: { options: models } } - ); - act(() => result.current.onDeepThinkingChange(true)); - act(() => result.current.onThinkingEffortChange("xhigh")); - act(() => result.current.onDeepThinkingChange(false)); - expect(result.current.snapshot?.generation_config).toMatchObject({ - deep_thinking: false, - }); - rerender({ options: [{ ...models[0], defaultEffort: "auto" }, models[1]] }); - act(() => result.current.onDeepThinkingChange(true)); - expect(result.current.thinkingEffort).toBe("auto"); - const budget = renderHook(() => - useConversationModelControls( - [ - { - id: "3", - name: "Budget", - budgetTokens: { min: 1024, max: 8192 }, - defaultBudgetTokens: 2048, - }, - ], - "budget-reset" - ) - ); - act(() => budget.result.current.onDeepThinkingChange(true)); - act(() => budget.result.current.onReasoningBudgetTokensChange(4096)); - act(() => budget.result.current.onDeepThinkingChange(false)); - act(() => budget.result.current.onDeepThinkingChange(true)); - expect(budget.result.current.reasoningBudgetTokens).toBe(2048); - }); +it("SU-AC-01 selects a model without creating a second parameter state", () => { + const { result } = renderHook( + () => useConversationModelControls(models, "select"), + { wrapper: Wrapper } + ); + expect(result.current.snapshot).toEqual({ model_id: 1 }); + expect(result.current).not.toHaveProperty("modelParameterSettings"); + expect(result.current).not.toHaveProperty("deepThinking"); + act(() => result.current.onModelChange("2")); + expect(result.current.snapshot).toEqual({ model_id: 2 }); +}); - it("blocks a rejected selection until explicit reselection and retains explicit sampling in immutable run controls", () => { - const { result, rerender } = renderHook( - ({ key }) => useConversationModelControls(models, key), - { initialProps: { key: "first" } } - ); - const first = result.current.snapshot; - rerender({ key: "first" }); - expect(result.current.snapshot).toBe(first); - act(() => - result.current.modelParameterSettings.onChange({ - temperature: 0.7, - top_p: 1, - }) +it("SU-AC-04 isolates selections by thread and keeps explicit choices through delayed defaults", () => { + const { result, rerender } = renderHook( + ({ scope }) => useConversationModelControls(models, scope, "1"), + { initialProps: { scope: "a" }, wrapper: Wrapper } + ); + act(() => result.current.onModelChange("2")); + rerender({ scope: "a" }); + expect(result.current.selectedModelId).toBe("2"); + rerender({ scope: "b" }); + expect(result.current.selectedModelId).toBe("1"); + rerender({ scope: "a" }); + expect(result.current.selectedModelId).toBe("2"); +}); + +it("SU-AC-05 rejects unavailable selections and never silently changes a removed explicit model", () => { + const { result, rerender } = renderHook( + ({ options }) => useConversationModelControls(options, "rejected"), + { initialProps: { options: models }, wrapper: Wrapper } + ); + act(() => rejectModelSelection(1)); + expect(result.current.canSend).toBe(false); + act(() => result.current.onModelChange("2")); + expect(result.current.canSend).toBe(true); + rerender({ options: [models[0]] }); + expect(result.current.selectedModelId).toBe("2"); + expect(result.current.canSend).toBe(false); + expect(result.current.snapshot).toBeUndefined(); + act(() => result.current.onModelChange("999")); + expect(result.current.selectedModelId).toBe("2"); +}); + +it.each(["agent", "workbench"])( + "SU-AC-01 restores and saves selection through the shared preference API (%s)", + async (mode) => { + const { result, unmount } = renderHook( + () => useConversationModelControls(models, mode, "1", undefined, 123), + { wrapper: Wrapper } ); - act(() => rejectModelSelection(1)); expect(result.current.canSend).toBe(false); - expect(result.current.modelParameterSettings.value).toEqual({ - temperature: 0.7, - top_p: 1, - }); - act(() => result.current.onModelChange("2")); + await waitFor(() => expect(result.current.selectedModelId).toBe("2")); expect(result.current.canSend).toBe(true); - expect( - result.current.modelParameterSettings.value.temperature - ).toBeUndefined(); act(() => result.current.onModelChange("1")); - expect(result.current.canSend).toBe(true); - expect(result.current.modelParameterSettings.value.temperature).toBe(0.7); - expect(result.current.snapshot?.generation_config).toMatchObject({ - temperature: 0.7, - top_p: 1, - }); - rerender({ key: "second" }); - expect( - result.current.modelParameterSettings.value.temperature - ).toBeUndefined(); - }); - it("keeps explicit selection through delayed defaults and preserves per-model reasoning", () => { - const { result, rerender } = renderHook( - ({ defaultId }) => - useConversationModelControls(models, "session-a", defaultId), - { initialProps: { defaultId: "1" } } + await waitFor(() => + expect(api.patchConversationModelSelection).toHaveBeenCalledWith( + 123, + 1, + 4 + ) ); - act(() => result.current.onModelChange("2")); - act(() => result.current.onDeepThinkingChange(false)); - rerender({ defaultId: "1" }); - expect(result.current.selectedModelId).toBe("2"); - expect(result.current.deepThinking).toBe(false); - act(() => result.current.onModelChange("1")); - act(() => result.current.onDeepThinkingChange(true)); - act(() => result.current.onThinkingEffortChange("xhigh")); - act(() => result.current.onModelChange("2")); - expect(result.current.deepThinking).toBe(false); - act(() => result.current.onModelChange("1")); - expect(result.current.snapshot).toEqual({ - model_id: 1, - generation_config: { deep_thinking: true, thinking_effort: "xhigh" }, - }); - }); - - it("isolates thread/user scopes and never falls back for a removed explicit model", () => { - const { result, rerender } = renderHook( - ({ key, options }) => useConversationModelControls(options, key, "1"), - { initialProps: { key: "tenant-a:user-a:thread-a", options: models } } + await waitFor(() => expect(result.current.selectedModelId).toBe("1")); + unmount(); + const restored = renderHook( + () => + useConversationModelControls( + models, + `${mode}-refresh`, + "2", + undefined, + 123 + ), + { wrapper: Wrapper } ); - act(() => result.current.onModelChange("2")); - const firstSnapshot = result.current.snapshot; - rerender({ key: "tenant-a:user-a:thread-b", options: models }); - expect(result.current.selectedModelId).toBe("1"); - rerender({ key: "tenant-a:user-a:thread-a", options: [models[0]] }); - expect(result.current.selectedModelId).toBe("2"); - expect(result.current.canSend).toBe(false); - expect(result.current.snapshot).toBeUndefined(); - expect(firstSnapshot?.model_id).toBe(2); - rerender({ key: "tenant-b:user-b:thread-a", options: models }); - expect(result.current.selectedModelId).toBe("1"); - }); + await waitFor(() => expect(restored.result.current.canSend).toBe(true)); + expect(restored.result.current.selectedModelId).toBe("1"); + } +); - it("resolves stale effort and budget to capabilities without fabricating generic levels", () => { - const budgetModel: ModelOption = { - id: "3", - name: "budget", - budgetTokens: { min: 1024, max: 4096 }, - }; - const { result } = renderHook(() => - useConversationModelControls([budgetModel], "budget-session") - ); - expect(result.current.snapshot?.generation_config).toMatchObject({ - thinking_effort: "auto", - reasoning_budget_tokens: 0, - }); - act(() => result.current.onReasoningBudgetTokensChange(8000)); - expect(result.current.reasoningBudgetTokens).toBe(4096); - const plain = renderHook(() => - useConversationModelControls( - [{ id: "4", name: "plain" }], - "plain-session" - ) - ); - act(() => plain.result.current.onDeepThinkingChange(true)); - expect(plain.result.current.deepThinking).toBe(false); - expect(plain.result.current.models[0].efforts).toBeUndefined(); - }); +it("SU-AC-05 keeps server selection after CAS rejection and blocks running when restoration fails", async () => { + api.patchConversationModelSelection.mockRejectedValueOnce( + new Error("conflict") + ); + const { result } = renderHook( + () => useConversationModelControls(models, "conflict", "1", undefined, 123), + { wrapper: Wrapper } + ); + await waitFor(() => expect(result.current.selectedModelId).toBe("2")); + act(() => result.current.onModelChange("1")); + await waitFor(() => + expect(api.patchConversationModelSelection).toHaveBeenCalled() + ); + await waitFor(() => expect(result.current.canSend).toBe(true)); + expect(result.current.selectedModelId).toBe("2"); + api.getConversationModelSelection.mockRejectedValueOnce( + new Error("forbidden") + ); + const failed = renderHook( + () => useConversationModelControls(models, "denied", "1", undefined, 124), + { wrapper: Wrapper } + ); + await waitFor(() => + expect(api.getConversationModelSelection).toHaveBeenCalledWith( + 124, + expect.anything() + ) + ); + expect(failed.result.current.canSend).toBe(false); +}); + +it("SU-AC-03 restores an unsent thread selection after its UI unmounts", () => { + const first = renderHook( + () => useConversationModelControls(models, "unsent-return", "1"), + { wrapper: Wrapper } + ); + act(() => first.result.current.onModelChange("2")); + first.unmount(); + const other = renderHook( + () => useConversationModelControls(models, "unsent-other", "1"), + { wrapper: Wrapper } + ); + expect(other.result.current.selectedModelId).toBe("1"); + other.unmount(); + const restored = renderHook( + () => useConversationModelControls(models, "unsent-return", "1"), + { wrapper: Wrapper } + ); + expect(restored.result.current.selectedModelId).toBe("2"); + expect(api.patchConversationModelSelection).not.toHaveBeenCalled(); +}); - it("snapshots only canonical runtime controls and keeps copied intent immutable", () => { - const values = { - deep_thinking: true, - thinking_effort: "xhigh" as const, - temperature: 0.7, - top_p: 0, - }; - const snapshot = snapshotTurnModelControls(2, values); - values.deep_thinking = false; - expect(snapshot).toEqual({ - model_id: 2, - generation_config: { - deep_thinking: true, - thinking_effort: "xhigh", - temperature: 0.7, - top_p: 0, - }, - }); - for (const id of [0, -1, 1.5, "main_model"]) - expect(() => snapshotTurnModelControls(id, {})).toThrow( - "model_not_available" - ); +it("SU-AC-04 exposes a recovery message for an unavailable saved selection", async () => { + api.getConversationModelSelection.mockResolvedValueOnce({ + selected_model_id: 999, + version: 4, }); + const { result } = renderHook( + () => + useConversationModelControls( + models, + "invalid-saved", + "1", + undefined, + 125 + ), + { wrapper: Wrapper } + ); + await waitFor(() => + expect(result.current.error).toBe("modelProfile.modelUnavailable") + ); + expect(result.current.canSend).toBe(false); + expect(result.current.selectedModelId).toBe("999"); }); diff --git a/frontend/tests/workbench/ConversationModelDrafts.test.ts b/frontend/tests/workbench/ConversationModelDrafts.test.ts new file mode 100644 index 0000000000..70012947ac --- /dev/null +++ b/frontend/tests/workbench/ConversationModelDrafts.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { beforeEach, expect, it, vi } from "vitest"; +import type { ChatModelRunOptions } from "@assistant-ui/react"; +import { ReadableStream } from "node:stream/web"; +import { + patchModelDraft, + persistModelDrafts, + readModelDraft, + snapshotModelDrafts, + acknowledgeModelDrafts, +} from "@/lib/conversationModelDrafts"; +import { remoteChatModelAdapter } from "@/app/newchat/adapter/remote-chat-model-adapter"; +const service = vi.hoisted(() => ({ create: vi.fn(), runAgent: vi.fn() })); +const api = vi.hoisted(() => ({ + getConversationModelPreferences: vi.fn(), + patchConversationModelPreferences: vi.fn(), +})); +vi.mock("@/services/conversationService", () => ({ + conversationService: service, +})); +vi.mock("@/services/modelProfileService", () => api); +vi.mock("@/lib/logger", () => ({ + default: { log: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); +beforeEach(() => { + vi.resetAllMocks(); + api.getConversationModelPreferences.mockResolvedValue({ version: 4 }); + api.patchConversationModelPreferences.mockResolvedValue(undefined); + service.runAgent.mockImplementation(async (_params, _signal, onIdentity) => { + onIdentity?.("29"); + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }).getReader(); + }); +}); +async function run(scope: string, custom: Record = {}) { + const input = { + messages: [ + { id: "q", role: "user", content: [{ type: "text", text: "hello" }] }, + ], + abortSignal: new AbortController().signal, + context: { config: { modelName: "42" } }, + runConfig: { custom }, + unstable_threadId: scope, + } as unknown as ChatModelRunOptions; + const result = remoteChatModelAdapter.run(input); + if (Symbol.asyncIterator in result) + for await (const chunk of result) { + void chunk; + } + else await result; +} +it.each([false, true])( + "SU-AC-02 sends and acknowledges identical first-turn preferences with Workbench=%s", + async (workbench) => { + const scope = `first-${workbench}`; + const values = { temperature: 0, top_p: 0.81, enable_thinking: true }; + patchModelDraft(scope, 42, values, []); + await run("adapter-thread", { + agentId: 7, + modelPreferenceScope: scope, + ...(workbench + ? { + workbenchConfig: { + schema_version: 4, + mode: "generic_chat", + agent_mounts: [], + skill_mounts: [], + }, + workbenchConfigVersion: 0, + } + : {}), + }); + expect(service.create).not.toHaveBeenCalled(); + expect(api.patchConversationModelPreferences).not.toHaveBeenCalled(); + const request = service.runAgent.mock.calls[0][0]; + expect(request.model_parameter_preferences).toEqual({ "42": values }); + expect(request.conversation_id).toBeUndefined(); + expect(request.entrypoint).toBe(workbench ? "workbench" : undefined); + expect(request).not.toHaveProperty("generation_config"); + expect(readModelDraft(scope, 42)).toEqual({}); + } +); +it("SU-AC-02 preserves first-turn choices after a rejected run, without creating an orphan ordinary conversation", async () => { + patchModelDraft("failed-first", 42, { temperature: 0.19 }, []); + service.runAgent.mockRejectedValueOnce(new Error("invalid preferences")); + await expect(run("failed-first")).rejects.toThrow("invalid preferences"); + expect(readModelDraft("failed-first", 42)).toEqual({ temperature: 0.19 }); + expect(service.create).not.toHaveBeenCalled(); + await run("failed-first"); + expect(readModelDraft("failed-first", 42)).toEqual({}); +}); +it("SU-AC-05 blocks an existing conversation run after failed CAS and retains the draft", async () => { + patchModelDraft("existing-failed", 42, { temperature: 0.3 }, []); + api.patchConversationModelPreferences.mockRejectedValueOnce( + new Error("conflict") + ); + await expect(run("existing-failed", { threadId: "31" })).rejects.toThrow( + "conflict" + ); + expect(service.runAgent).not.toHaveBeenCalled(); + expect(readModelDraft("existing-failed", 42)).toEqual({ temperature: 0.3 }); +}); +it.each([false, true])( + "SU-AC-01 persists existing conversation drafts through the shared CAS endpoint with Workbench=%s", + async (workbench) => { + const scope = `existing-${workbench}`; + patchModelDraft(scope, 42, { temperature: 0.41 }, []); + await run(scope, { + threadId: "31", + ...(workbench + ? { + workbenchConfig: { + schema_version: 4, + mode: "generic_chat", + agent_mounts: [], + skill_mounts: [], + }, + } + : {}), + }); + expect(api.patchConversationModelPreferences).toHaveBeenCalledWith(31, 42, { + expected_version: 4, + parameter_values: { temperature: 0.41 }, + delete_parameters: [], + }); + expect( + service.runAgent.mock.calls[0][0].model_parameter_preferences + ).toBeUndefined(); + expect(service.create).not.toHaveBeenCalled(); + } +); +it("SU-AC-04 isolates models and threads and preserves edits made while a frozen draft is in flight", () => { + patchModelDraft("concurrent", 42, { temperature: 0.2, top_p: 0 }, []); + patchModelDraft("concurrent", 43, { enable_thinking: false }, []); + patchModelDraft("other", 42, { temperature: 0.9 }, []); + const sent = snapshotModelDrafts("concurrent"); + patchModelDraft("concurrent", 42, { temperature: 0.4 }, []); + acknowledgeModelDrafts("concurrent", sent); + expect(readModelDraft("concurrent", 42)).toEqual({ temperature: 0.4 }); + expect(readModelDraft("concurrent", 43)).toEqual({}); + expect(readModelDraft("other", 42)).toEqual({ temperature: 0.9 }); + expect(sent["42"]).toEqual({ temperature: 0.2, top_p: 0 }); +}); +it("does not persist untouched drafts or create any conversation from the draft utility", async () => { + patchModelDraft("deleted", 42, { top_p: 0.8 }, []); + patchModelDraft("deleted", 42, {}, ["top_p"]); + expect(await persistModelDrafts("deleted")).toBeUndefined(); + expect(service.create).not.toHaveBeenCalled(); + expect(api.patchConversationModelPreferences).not.toHaveBeenCalled(); +}); diff --git a/frontend/tests/workbench/ModelParameterSettings.test.tsx b/frontend/tests/workbench/ModelParameterSettings.test.tsx index 240b052cbb..357dd8f2c9 100644 --- a/frontend/tests/workbench/ModelParameterSettings.test.tsx +++ b/frontend/tests/workbench/ModelParameterSettings.test.tsx @@ -280,3 +280,15 @@ it("restoring discards an uncommitted numeric draft without submitting or saving ); expect(screen.getByRole("spinbutton", { name: "温度" })).toHaveValue(0.6); }); + +it("UI-SESSION-15 keeps the submenu open when focus and click target the same trigger", async () => { + render(); + const trigger = screen.getByRole("button", { name: "高级设置" }); + await userEvent.click(trigger); + await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "true")); + expect( + await screen.findByRole("spinbutton", { name: "温度" }) + ).toBeInTheDocument(); + fireEvent.keyDown(trigger, { key: "Escape" }); + expect(trigger).toHaveAttribute("aria-expanded", "false"); +}); diff --git a/frontend/tests/workbench/ModelSelectorThinking.test.tsx b/frontend/tests/workbench/ModelSelectorThinking.test.tsx index 37719206ff..47425fa4d4 100644 --- a/frontend/tests/workbench/ModelSelectorThinking.test.tsx +++ b/frontend/tests/workbench/ModelSelectorThinking.test.tsx @@ -58,381 +58,49 @@ vi.mock("@/components/ui/command", () => ({ CommandSeparator: () => null, })); -const models = [{ id: "12", name: "Qwen", efforts: true }]; - -it("does not fabricate options when the selected model has no supported effort controls", async () => { - render( - - ); - const user = userEvent.setup(); - await user.click(screen.getByRole("switch")); - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); - expect(screen.queryByText("High")).toBeNull(); - expect(register.mock.lastCall?.[0].getModelContext()).toMatchObject({ - config: { modelName: "12", deepThinking: false }, - }); - const config = register.mock.lastCall?.[0].getModelContext() as { - config: Record; - }; - expect(config.config).not.toHaveProperty("reasoningEffort"); -}); - -const localizedEfforts = [ - ["auto", "自动", "auto"], - ["none", "无", "None"], - ["minimal", "最低", "Minimal"], - ["low", "低", "Low"], - ["medium", "中", "Medium"], - ["high", "高", "High"], - ["xhigh", "极高", "Extra high"], - ["max", "最高", "Maximum"], -] as const; - -it.each(["zh", "en"] as const)( - "localizes every supported effort in %s without changing model context or callbacks", - async (language) => { - const i18n = createInstance(); - await i18n.init({ - lng: language, - fallbackLng: false, - keySeparator: false, - resources: { zh: { translation: zh }, en: { translation: en } }, - }); - const changed = vi.fn(); - const capability = { - status: "supported" as const, - control: "effort" as const, - levels: localizedEfforts.map(([id]) => id), - source: "catalog" as const, - }; - const choices = [ - { - id: "12", - name: "Qwen", - efforts: localizedEfforts.map(([id]) => ({ id, name: id })), - reasoningCapability: capability, - }, - ]; - const view = (thinking: boolean) => ( - - - - ); - const { rerender } = render(view(true)); - const user = userEvent.setup(); - for (const [id, chinese, english] of localizedEfforts) { - const label = language === "zh" ? chinese : english; - await user.click(screen.getByRole("combobox")); - await user.click(screen.getByText(label, { exact: true })); - expect(changed).toHaveBeenLastCalledWith(id); - expect(screen.getByRole("combobox")).toHaveTextContent(label); - expect(register.mock.lastCall?.[0].getModelContext()).toEqual({ - config: { - modelName: "12", - deepThinking: true, - reasoningCapability: capability, - reasoningEffort: id, - }, - }); - } - rerender(view(false)); - expect(register.mock.lastCall?.[0].getModelContext()).toEqual({ - config: { - modelName: "12", - deepThinking: false, - reasoningCapability: capability, - }, - }); - } -); +const profileEditor = vi.hoisted(() => vi.fn()); +vi.mock("@/components/model/ConversationModelParameters", () => ({ + ConversationModelParameters: (props: Record) => { + profileEditor(props); + return
; + }, +})); -it("keeps an unknown provider effort's fallback label and raw value", async () => { - const i18n = createInstance(); - await i18n.init({ - lng: "zh", - keySeparator: false, - resources: { zh: { translation: zh } }, - }); - const changed = vi.fn(); - render( - +it.each([undefined, 123])( + "SU-AC-01 uses the same profile editor for draft and saved Agent conversations (%s)", + (conversationId) => { + render( - - ); - await userEvent.click(screen.getByRole("combobox")); - await userEvent.click(screen.getByText("Provider custom effort")); - expect(changed).toHaveBeenLastCalledWith("provider-custom"); - expect(register.mock.lastCall?.[0].getModelContext()).toMatchObject({ - config: { reasoningEffort: "provider-custom" }, - }); -}); - -it("shared advanced settings remains available with and without controlled values, outside model context", () => { - const parameterSettings = { - value: { temperature: 0.5, top_p: 0.9 }, - defaultValue: { temperature: 0.5, top_p: 0.9 }, - onChange: vi.fn(), - }; - const { rerender } = render( - - ); - const advanced = screen.getByRole("button", { name: "高级设置" }); - expect(advanced.querySelector(".lucide-settings")).toBeNull(); - expect( - screen.getByRole("switch").compareDocumentPosition(advanced) & - Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy(); - const config = register.mock.lastCall?.[0].getModelContext() as { - config: Record; - }; - expect(config.config).toMatchObject({ temperature: 0.5, top_p: 0.9 }); - rerender(); - expect( - screen.queryByRole("button", { name: "高级设置" }) - ).toBeInTheDocument(); -}); - -it("does not re-register model context when equivalent model objects rerender", () => { - register.mockClear(); - const { rerender } = render( - - ); - const firstRegistrationCount = register.mock.calls.length; - rerender( - - ); - expect(register).toHaveBeenCalledTimes(firstRegistrationCount); -}); - -it("shows settings even without a selected model and forwards the first selection", async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - const { container } = render( - - ); - - expect( - container.querySelector('[data-slot="model-selector-value"]') - ).not.toHaveTextContent("Qwen"); - expect(screen.getByRole("switch")).toBeInTheDocument(); - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Qwen" })); - expect(onValueChange).toHaveBeenCalledExactlyOnceWith("12"); -}); - -it("lets Start Chat toggle deep thinking and choose an effort in the shared selector", async () => { - const user = userEvent.setup(); - render(); - const thinking = screen.getByRole("switch"); - expect(thinking).toHaveAttribute("aria-checked", "false"); - await user.click(thinking); - expect(thinking).toHaveAttribute("aria-checked", "true"); - const effort = screen.getByRole("combobox"); - await user.click(effort); - await user.click(screen.getByText("Medium")); - expect(register.mock.lastCall?.[0].getModelContext()).toMatchObject({ - config: { modelName: "12", deepThinking: true, reasoningEffort: "medium" }, - }); -}); - -it("defaults the shared selector to low reasoning effort", async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByRole("switch")); - - expect(register.mock.lastCall?.[0].getModelContext()).toMatchObject({ - config: { modelName: "12", deepThinking: true, reasoningEffort: "low" }, - }); -}); - -it("lets Workbench control the same selector settings", async () => { - const user = userEvent.setup(); - const onDeepThinkingChange = vi.fn(); - const onEffortChange = vi.fn(); - render( - - ); - await user.click(screen.getByRole("switch")); - expect(onDeepThinkingChange).toHaveBeenCalledWith(false); - await user.click(screen.getByRole("combobox")); - await user.click(screen.getByText("High")); - expect(onEffortChange).toHaveBeenCalledWith("high"); -}); + ); + expect(screen.getByTestId("shared-profile-editor")).toBeVisible(); + expect(profileEditor).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId, + draftScope: "thread-scope", + modelId: 42, + agentDefaults: { temperature: 0.4 }, + }) + ); + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + } +); -it("passes capability metadata without forcing reasoning when deep thinking is off", () => { - const capability = { - status: "supported" as const, - control: "effort" as const, - levels: ["low" as const, "high" as const], - source: "catalog" as const, - }; +it("SU-AC-05 does not fabricate a legacy parameter editor for an unresolved outlet", () => { render( - - ); - const config = ( - register.mock.lastCall?.[0].getModelContext() as { - config: Record; - } - ).config; - expect(config).toMatchObject({ - modelName: "12", - deepThinking: false, - reasoningCapability: capability, - }); - expect(config).not.toHaveProperty("reasoningEffort"); -}); - -it("default shared settings isolate local values by model and conversation without requests", async () => { - const choices = [ - ...models, - { id: "13", name: "Second Model", efforts: true }, - ]; - const requests = vi.spyOn(globalThis, "fetch"); - const view = (value: string, scope: string) => ( - - ); - const { rerender } = render(view("12", "thread-a")); - const openPanel = async () => { - fireEvent.keyDown(screen.getByRole("button", { name: "高级设置" }), { - key: "Enter", - }); - return within(await screen.findByRole("region", { name: "高级设置" })); - }; - let panel = await openPanel(); - const temperature = panel.getByRole("spinbutton", { name: "温度" }); - fireEvent.change(temperature, { target: { value: "0.7" } }); - fireEvent.keyDown(temperature, { key: "Enter" }); - expect(panel.getByRole("slider", { name: "温度 滑块" })).toHaveAttribute( - "aria-valuenow", - "0.7" - ); - rerender(view("13", "thread-a")); - panel = await openPanel(); - expect(panel.getByRole("spinbutton", { name: "温度" })).toHaveValue(null); - rerender(view("12", "thread-b")); - panel = await openPanel(); - expect(panel.getByRole("spinbutton", { name: "温度" })).toHaveValue(null); - rerender(view("12", "thread-a")); - panel = await openPanel(); - expect(panel.getByRole("spinbutton", { name: "温度" })).toHaveValue(0.7); - const config = register.mock.lastCall?.[0].getModelContext() as { - config: Record; - }; - expect(config.config.modelName).toBe("12"); - expect(config.config).toMatchObject({ temperature: 0.7 }); - expect(config.config).not.toHaveProperty("top_p"); - expect(requests).not.toHaveBeenCalled(); -}); - -it("shared parameter editing honors disabled state and controlled callers", async () => { - const changed = vi.fn(); - const control = { - scopeKey: "controlled", - value: { temperature: 0.6, top_p: 0.8 }, - defaultValue: { temperature: 0.5, top_p: 0.9 }, - onChange: changed, - }; - const { rerender } = render( - - ); - fireEvent.keyDown(screen.getByRole("button", { name: "高级设置" }), { - key: "Enter", - }); - const input = await screen.findByRole("spinbutton", { name: "温度" }); - expect(input).toHaveValue(0.6); - fireEvent.change(input, { target: { value: "0.8" } }); - fireEvent.keyDown(input, { key: "Enter" }); - expect(changed).toHaveBeenLastCalledWith({ - temperature: 0.8, - top_p: 0.8, - }); - rerender( - - ); - expect(screen.getByRole("spinbutton", { name: "温度" })).toHaveValue(0.8); - rerender( - - ); - expect(screen.getByRole("button", { name: "高级设置" })).toBeDisabled(); - expect(screen.getByRole("spinbutton", { name: "温度" })).toBeDisabled(); - expect(screen.getByRole("slider", { name: "温度 滑块" })).toHaveAttribute( - "aria-disabled", - "true" - ); -}); - -it("collapses reasoning while off and restores the controls without changing supported options", async () => { - const view = (enabled: boolean) => ( - + ); - const { rerender } = render(view(false)); - expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false"); - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); - expect(screen.queryByText("思考强度")).not.toBeInTheDocument(); - rerender(view(true)); - expect(screen.getByRole("combobox")).toBeEnabled(); - await userEvent.click(screen.getByRole("combobox")); - expect(screen.getByRole("option", { name: "High" })).toBeVisible(); + expect(screen.queryByTestId("shared-profile-editor")).not.toBeInTheDocument(); + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); }); diff --git a/frontend/tests/workbench/RuntimeEvents.test.ts b/frontend/tests/workbench/RuntimeEvents.test.ts index 9536ccc719..4e09d0f8bb 100644 --- a/frontend/tests/workbench/RuntimeEvents.test.ts +++ b/frontend/tests/workbench/RuntimeEvents.test.ts @@ -1,14 +1,16 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import { expect, it, vi } from "vitest"; import { notifyWorkbenchConfigResolved } from "@/features/workbench/runtimeEvents"; it("UT-FE-WB-034 accepts normalized version acknowledgements as object or JSON", () => { const version = vi.fn(); notifyWorkbenchConfigResolved( - { schema_version: 3, config_version: 2 }, + { schema_version: 4, config_version: 2 }, version ); notifyWorkbenchConfigResolved( - '{"schema_version":3,"config_version":4}', + '{"schema_version":4,"config_version":4}', version ); expect(version.mock.calls).toEqual([[2], [4]]); @@ -19,8 +21,8 @@ it("UT-FE-WB-034 ignores malformed or incompatible version acknowledgements", () null, "{", { schema_version: 2, config_version: 2 }, - { schema_version: 3, config_version: -1 }, - { schema_version: 3, config_version: "3" }, + { schema_version: 4, config_version: -1 }, + { schema_version: 4, config_version: "3" }, ]) { notifyWorkbenchConfigResolved(value, version); } diff --git a/frontend/tests/workbench/StreamCancellation.test.ts b/frontend/tests/workbench/StreamCancellation.test.ts index 5f9541dd7f..1b56f423d5 100644 --- a/frontend/tests/workbench/StreamCancellation.test.ts +++ b/frontend/tests/workbench/StreamCancellation.test.ts @@ -223,7 +223,7 @@ it("UT-FE-WB-003 returning to a running conversation uses resume instead of star expect(service.stop).not.toHaveBeenCalled(); }); -it("passes Start Chat thinking settings to the normal agent run", async () => { +it("SU-AC-07 does not revive removed controls from a stale ModelContext", async () => { service.runAgent.mockResolvedValue({ type: "json", data: { status: "completed" }, @@ -243,8 +243,10 @@ it("passes Start Chat thinking settings to the normal agent run", async () => { expect(service.runAgent.mock.calls[0][0]).toMatchObject({ model_id: 12, - generation_config: { deep_thinking: true, thinking_effort: "medium" }, }); + expect(service.runAgent.mock.calls[0][0]).not.toHaveProperty( + "generation_config" + ); }); const sourceHits = [ diff --git a/frontend/tests/workbench/WorkbenchPageLifecycle.test.tsx b/frontend/tests/workbench/WorkbenchPageLifecycle.test.tsx index 89fc841172..87ff87d261 100644 --- a/frontend/tests/workbench/WorkbenchPageLifecycle.test.tsx +++ b/frontend/tests/workbench/WorkbenchPageLifecycle.test.tsx @@ -54,15 +54,7 @@ const runtime = { }; const config = { ...initialWorkbenchState.config, - model_id: 11, - generation_config: { - deep_thinking: true, - thinking_effort: "xhigh" as const, - reasoning_budget_tokens: 4096, - requested_output_tokens: 1234, - temperature: 0.7, - top_p: 0.8, - }, + requested_output_tokens: 1234, }; const translate = (_key: string, fallback?: unknown) => typeof fallback === "string" ? fallback : _key; @@ -314,13 +306,13 @@ it("ignores delayed loads and saves from an inactive conversation", async () => ); await act(async () => { saveB.resolve({ - workbench_config: { ...config, model_id: 99 }, + workbench_config: { ...config, requested_output_tokens: 999 }, workbench_config_version: 999, }); await saving; }); expect(runConfig().threadId).toBe("101"); - expect(runConfig().workbenchConfig.model_id).toBe(11); + expect(runConfig().workbenchConfig.requested_output_tokens).toBe(1234); expect(runConfig().workbenchConfigVersion).toBe(101); }); @@ -331,12 +323,10 @@ it("uses the server-issued ID first and preserves exact model config and version expect(runConfig().threadId).toBeUndefined(); await act(async () => runConfig().onServerConversationId("303")); await waitFor(() => expect(runConfig().threadId).toBe("303")); - expect(runConfig().workbenchConfig.generation_config).toEqual( - config.generation_config - ); + expect(runConfig().workbenchConfig.requested_output_tokens).toBe(1234); expect(runConfig().workbenchConfigVersion).toBe(303); expect(view.props?.conversationId).toBe(303); - expect(view.props?.thinkingEffort).toBe("xhigh"); + expect(runConfig().workbenchConfig).not.toHaveProperty("generation_config"); }); it("restores a local chat snapshot after entering and leaving creation", async () => { @@ -402,8 +392,11 @@ it("restores generated local Skill files after visiting another creation thread" expect(runConfig().draftSnapshot.files).toEqual([ { path: "SKILL.md", content: "saved skill file" }, ]); - expect(runConfig().creationWorkbenchConfig.generation_config).toEqual( - config.generation_config + expect(runConfig().creationWorkbenchConfig.requested_output_tokens).toBe( + 1234 + ); + expect(runConfig().creationWorkbenchConfig).not.toHaveProperty( + "generation_config" ); }); diff --git a/frontend/tests/workbench/workbenchNewConversation.test.tsx b/frontend/tests/workbench/workbenchNewConversation.test.tsx index 62e0338f6f..b1ece47b3c 100644 --- a/frontend/tests/workbench/workbenchNewConversation.test.tsx +++ b/frontend/tests/workbench/workbenchNewConversation.test.tsx @@ -256,9 +256,12 @@ describe("workbench new conversation", () => { it("reloads the conversation list after starting a new conversation", async () => { const user = userEvent.setup(); - const queryClient = new QueryClient(); render( - + ); diff --git a/frontend/tests/workbenchState.test.ts b/frontend/tests/workbenchState.test.ts index ce31200037..fe844e93a7 100644 --- a/frontend/tests/workbenchState.test.ts +++ b/frontend/tests/workbenchState.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import assert from "node:assert/strict"; import test from "node:test"; @@ -42,7 +44,7 @@ test("UT-FE-WB-009 creation actions clear incompatible resources", () => { assert.equal(creation.config.mode, "agent_create"); assert.deepEqual(creation.config.agent_mounts, []); assert.deepEqual(creation.config.skill_mounts, []); - assert.equal(creation.config.model_id, undefined); + assert.equal("model_id" in creation.config, false); }); test("UT-FE-WB-006 locks Agent version without mounting its Skill defaults on the Workbench root", () => {