Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions backend/app/services/adapters/bot_kinds.py
Original file line number Diff line number Diff line change
Expand Up @@ -603,11 +603,18 @@ def update_with_user(

# Update bot's modelRef
bot_crd = Bot.model_validate(bot.json)
from app.schemas.kind import ModelRef
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Consolidate duplicate imports to function or file level.

The import from app.schemas.kind import ModelRef appears twice in this function (lines 606 and 748). Move the import to the top of the function to avoid redundancy.

Apply this diff to move the import to the function start:

     ) -> Dict[str, Any]:
         """
         Update user Bot
         """
         import logging
 
         logger = logging.getLogger(__name__)
+        from app.schemas.kind import ModelRef
 
         bot = (

Then remove both inline imports:

                 # Update bot's modelRef
                 bot_crd = Bot.model_validate(bot.json)
-                from app.schemas.kind import ModelRef
-
                 if bot_crd.spec.modelRef:
                     # Update bot's modelRef to point to the new dedicated model
                     bot_crd = Bot.model_validate(bot.json)
-                    from app.schemas.kind import ModelRef
-
                     if bot_crd.spec.modelRef:

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In backend/app/services/adapters/bot_kinds.py around lines 606 and 748, the
import "from app.schemas.kind import ModelRef" is duplicated inline; move a
single "from app.schemas.kind import ModelRef" to the top of the enclosing
function (immediately after the function signature) and remove the two inline
import statements at lines ~606 and ~748 so ModelRef is imported once per
function scope.


if bot_crd.spec.modelRef:
bot_crd.spec.modelRef.name = model_name
bot_crd.spec.modelRef.namespace = "default"
bot.json = bot_crd.model_dump()
flag_modified(bot, "json")
else:
# Create new modelRef if it doesn't exist
bot_crd.spec.modelRef = ModelRef(
name=model_name, namespace="default"
)
bot.json = bot_crd.model_dump()
flag_modified(bot, "json")

# Only delete old model if it's a user's private custom model (not public or predefined)
# A private custom model must satisfy:
Expand Down
26 changes: 23 additions & 3 deletions frontend/src/features/tasks/components/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { Send, CircleStop } from 'lucide-react';
import MessagesArea from './MessagesArea';
import ChatInput from './ChatInput';
import TeamSelector from './TeamSelector';
import ModelSelector, { Model, DEFAULT_MODEL_NAME } from './ModelSelector';
import ModelSelector, {
Model,
DEFAULT_MODEL_NAME,
allBotsHavePredefinedModel,
} from './ModelSelector';
import RepositorySelector from './RepositorySelector';
import BranchSelector from './BranchSelector';
import LoadingDots from './LoadingDots';
Expand Down Expand Up @@ -172,6 +176,18 @@ export default function ChatArea({
return teamNameLength + modelNameLength > COMPACT_QUOTA_NAME_THRESHOLD;
}, [isMobile, selectedTeam?.name, selectedModel?.name]);

// Check if model selection is required but not fulfilled
// For legacy teams without predefined models, user MUST select a model before sending
const isModelSelectionRequired = React.useMemo(() => {
// Skip check if team is not selected, or if team type is 'dify' (external API)
if (!selectedTeam || selectedTeam.agent_type === 'dify') return false;
// If team's bots have predefined models, "Default" option is available, no need to force selection
const hasDefaultOption = allBotsHavePredefinedModel(selectedTeam);
if (hasDefaultOption) return false;
// Model selection is required when no model is selected
return !selectedModel;
}, [selectedTeam, selectedModel]);

const handleTeamChange = (team: Team | null) => {
console.log('[ChatArea] handleTeamChange called:', team?.name || 'null', team?.id || 'null');
setSelectedTeam(team);
Expand Down Expand Up @@ -580,7 +596,9 @@ export default function ChatArea({
size="icon"
onClick={handleSendMessage}
disabled={
isLoading || (shouldHideChatInput ? false : !taskInputMessage.trim())
isLoading ||
isModelSelectionRequired ||
(shouldHideChatInput ? false : !taskInputMessage.trim())
}
className="h-6 w-6 rounded-full hover:bg-primary/10 flex-shrink-0 translate-y-0.5"
>
Expand Down Expand Up @@ -722,7 +740,9 @@ export default function ChatArea({
size="icon"
onClick={handleSendMessage}
disabled={
isLoading || (shouldHideChatInput ? false : !taskInputMessage.trim())
isLoading ||
isModelSelectionRequired ||
(shouldHideChatInput ? false : !taskInputMessage.trim())
}
className="h-6 w-6 rounded-full hover:bg-primary/10 flex-shrink-0 translate-y-0.5"
>
Expand Down
83 changes: 66 additions & 17 deletions frontend/src/features/tasks/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface Model {
name: string;
provider: string; // 'openai' | 'claude'
modelId: string;
displayName?: string | null; // Human-readable display name
type?: ModelTypeEnum; // 'public' | 'user' - identifies model source
}

Expand Down Expand Up @@ -67,12 +68,19 @@ function unifiedToModel(unified: UnifiedModel): Model {
name: unified.name,
provider: unified.provider || 'claude',
modelId: unified.modelId || '',
displayName: unified.displayName,
type: unified.type,
};
}

// Helper function to get display text for a model: displayName(modelId) or name(modelId)
function getModelDisplayText(model: Model): string {
return model.displayName ? `${model.displayName}(${model.name})` : model.name;
}

// Helper function to check if all bots in a team have predefined models
function allBotsHavePredefinedModel(team: TeamWithBotDetails | null): boolean {
// Exported for use in ChatArea to determine if model selection is required
export function allBotsHavePredefinedModel(team: TeamWithBotDetails | null): boolean {
if (!team || !team.bots || team.bots.length === 0) {
return false;
}
Expand Down Expand Up @@ -169,9 +177,36 @@ export default function ModelSelector({
fetchModels();
}, [fetchModels]);

// Restore last selected model from localStorage or set default
// Track previous team ID to detect team changes and re-validate model selection
const prevTeamIdRef = React.useRef<number | null>(null);

// Re-validate model selection when team changes
useEffect(() => {
const currentTeamId = selectedTeam?.id ?? null;
const teamChanged = prevTeamIdRef.current !== null && prevTeamIdRef.current !== currentTeamId;
prevTeamIdRef.current = currentTeamId;

if (!teamChanged) return;

// Team changed - re-validate model selection based on new team's showDefaultOption
if (showDefaultOption) {
// New team supports default option, set to default
if (!selectedModel || selectedModel.name !== DEFAULT_MODEL_NAME) {
setSelectedModel({ name: DEFAULT_MODEL_NAME, provider: '', modelId: '' });
}
} else {
// New team does NOT support default option, clear model selection
// User must re-select a compatible model
setSelectedModel(null);
}
}, [selectedTeam?.id, showDefaultOption, selectedModel, setSelectedModel]);

// Restore last selected model from localStorage or set default (only on initial load)
useEffect(() => {
// When team changes or all bots have predefined models, auto-select default
// Skip if team has changed (handled by the above effect)
// This effect only handles initial load and model list changes

// When all bots have predefined models, auto-select default
if (showDefaultOption) {
// If all bots have predefined models, auto-select "Default"
if (!selectedModel || selectedModel.name !== DEFAULT_MODEL_NAME) {
Expand Down Expand Up @@ -254,18 +289,29 @@ export default function ModelSelector({
// Determine if selector should be disabled
const isDisabled = disabled || externalLoading || isLoading || isMixedTeam;

// Check if model selection is required (for legacy teams without predefined models)
const isModelRequired = !showDefaultOption && !selectedModel;

// Get display text for trigger
const getTriggerDisplayText = () => {
if (!selectedModel) {
return isLoading ? t('actions.loading') : t('task_submit.select_model', '选择模型');
if (isLoading) {
return t('actions.loading');
}
// Show required hint for legacy teams without predefined models
if (isModelRequired) {
return t('task_submit.model_required', '请选择模型');
}
return t('task_submit.select_model', '选择模型');
}
if (selectedModel.name === DEFAULT_MODEL_NAME) {
return t('task_submit.default_model', '默认');
}
const displayText = getModelDisplayText(selectedModel);
if (forceOverride && !isMixedTeam) {
return `${selectedModel.name}(${t('task_submit.override_short', '覆盖')})`;
return `${displayText}(${t('task_submit.override_short', '覆盖')})`;
}
return selectedModel.name;
return displayText;
};

return (
Expand All @@ -276,7 +322,7 @@ export default function ModelSelector({
style={{ maxWidth: isMobile ? 140 : 180, minWidth: isMobile ? 50 : 70 }}
>
<CpuChipIcon
className={`w-3 h-3 text-text-muted flex-shrink-0 ml-1 ${isLoading || externalLoading ? 'animate-pulse' : ''}`}
className={`w-3 h-3 flex-shrink-0 ml-1 ${isModelRequired ? 'text-error' : 'text-text-muted'} ${isLoading || externalLoading ? 'animate-pulse' : ''}`}
/>
<div className="relative min-w-0 flex-1">
<Popover open={isOpen} onOpenChange={setIsOpen}>
Expand All @@ -288,7 +334,8 @@ export default function ModelSelector({
disabled={isDisabled}
className={cn(
'flex h-9 w-full min-w-0 items-center justify-between rounded-lg text-left',
'bg-transparent px-0 text-xs text-text-muted',
'bg-transparent px-0 text-xs',
isModelRequired ? 'text-error' : 'text-text-muted',
'hover:bg-transparent transition-colors',
'focus:outline-none focus:ring-0',
'disabled:cursor-not-allowed disabled:opacity-50'
Expand Down Expand Up @@ -370,7 +417,7 @@ export default function ModelSelector({
{filteredModels.map(model => (
<CommandItem
key={getModelKey(model)}
value={`${model.name} ${model.provider} ${model.modelId} ${model.type}`}
value={`${model.name} ${model.displayName || ''} ${model.provider} ${model.modelId} ${model.type}`}
onSelect={() => handleModelSelect(getModelKey(model))}
className={cn(
'group cursor-pointer select-none',
Expand All @@ -396,22 +443,24 @@ export default function ModelSelector({
<div className="flex items-center gap-1.5">
<span
className="font-medium text-xs text-text-secondary truncate"
title={model.name}
title={getModelDisplayText(model)}
>
{model.name}
{getModelDisplayText(model)}
</span>
{model.type === 'public' && (
<Tag variant="info" className="text-[10px]">
{t('models.public', '公共')}
</Tag>
)}
</div>
<span
className="text-[10px] text-text-muted truncate mt-0.5"
title={model.modelId}
>
{model.modelId}
</span>
{model.modelId && (
<span
className="text-[10px] text-text-muted truncate"
title={model.modelId}
>
{model.modelId}
</span>
)}
</div>
</div>
</CommandItem>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@
"override_short": "Override",
"mixed_team_warning": "This team contains multiple executor types, cannot specify a unified model",
"default_model": "Default",
"use_bot_model": "Use Bot's predefined model"
"use_bot_model": "Use Bot's predefined model",
"model_required": "Please select a model"
}
}
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@
"override_short": "覆盖",
"mixed_team_warning": "当前团队包含多种执行器类型,无法统一指定模型",
"default_model": "默认绑定模型",
"use_bot_model": "使用 Bot 预设模型"
"use_bot_model": "使用 Bot 预设模型",
"model_required": "请选择模型"
}
}