diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index 646e8894dc..446e3a8885 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -34,13 +34,15 @@ const ITEM_HEIGHT = 36 interface PopupParams { model?: Model + modelFilter?: (model: Model) => boolean } interface Props extends PopupParams { resolve: (value: Model | undefined) => void + modelFilter?: (model: Model) => boolean } -const PopupContainer: React.FC = ({ model, resolve }) => { +const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { const { t } = useTranslation() const { providers } = useProviders() const { pinnedModels, togglePinnedModel, loading } = usePinnedModels() @@ -156,7 +158,10 @@ const PopupContainer: React.FC = ({ model, resolve }) => { // 添加置顶模型分组(仅在无搜索文本时) if (searchText.length === 0 && pinnedModels.length > 0) { const pinnedItems = providers.flatMap((p) => - p.models.filter((m) => pinnedModels.includes(getModelUniqId(m))).map((m) => createModelItem(m, p, true)) + p.models + .filter((m) => pinnedModels.includes(getModelUniqId(m))) + .filter(modelFilter ? modelFilter : () => true) + .map((m) => createModelItem(m, p, true)) ) if (pinnedItems.length > 0) { @@ -174,9 +179,9 @@ const PopupContainer: React.FC = ({ model, resolve }) => { // 添加常规模型分组 providers.forEach((p) => { - const filteredModels = getFilteredModels(p).filter( - (m) => searchText.length > 0 || !pinnedModels.includes(getModelUniqId(m)) - ) + const filteredModels = getFilteredModels(p) + .filter((m) => searchText.length > 0 || !pinnedModels.includes(getModelUniqId(m))) + .filter(modelFilter ? modelFilter : () => true) if (filteredModels.length === 0) return @@ -199,7 +204,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { firstGroupRef.current = null } return items - }, [providers, getFilteredModels, pinnedModels, searchText, t, createModelItem]) + }, [searchText.length, pinnedModels, providers, modelFilter, createModelItem, t, getFilteredModels]) // 获取可选择的模型项(过滤掉分组标题) const modelItems = useMemo(() => { diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 48560007ca..754162e6a7 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2907,3 +2907,12 @@ export function isDoubaoThinkingAutoModel(model: Model): boolean { } export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$') + +// 模型集合功能测试 +export const isVisionModels = (models: Model[]) => { + return models.every((model) => isVisionModel(model)) +} + +export const isGenerateImageModels = (models: Model[]) => { + return models.every((model) => isGenerateImageModel(model)) +} diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index 44b8772d75..a06229bd7c 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -1,9 +1,7 @@ -import { isGenerateImageModel, isVisionModel } from '@renderer/config/models' -import { FileType, Model } from '@renderer/types' -import { documentExts, imageExts, textExts } from '@shared/config/constant' +import { FileType } from '@renderer/types' import { Tooltip } from 'antd' import { Paperclip } from 'lucide-react' -import { FC, useCallback, useImperativeHandle, useMemo } from 'react' +import { FC, useCallback, useImperativeHandle } from 'react' import { useTranslation } from 'react-i18next' export interface AttachmentButtonRef { @@ -12,30 +10,25 @@ export interface AttachmentButtonRef { interface Props { ref?: React.RefObject - model: Model + couldAddImageFile: boolean + extensions: string[] files: FileType[] setFiles: (files: FileType[]) => void ToolbarButton: any disabled?: boolean } -const AttachmentButton: FC = ({ ref, model, files, setFiles, ToolbarButton, disabled }) => { +const AttachmentButton: FC = ({ + ref, + couldAddImageFile, + extensions, + files, + setFiles, + ToolbarButton, + disabled +}) => { const { t } = useTranslation() - // const extensions = useMemo( - // () => (isVisionModel(model) ? [...imageExts, ...documentExts, ...textExts] : [...documentExts, ...textExts]), - // [model] - // ) - const extensions = useMemo(() => { - if (isVisionModel(model)) { - return [...imageExts, ...documentExts, ...textExts] - } else if (isGenerateImageModel(model)) { - return [...imageExts] - } else { - return [...documentExts, ...textExts] - } - }, [model]) - const onSelectFile = useCallback(async () => { const _files = await window.api.file.select({ properties: ['openFile', 'multiSelections'], @@ -61,12 +54,7 @@ const AttachmentButton: FC = ({ ref, model, files, setFiles, ToolbarButto })) return ( - + diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 462ef0adb6..fe550510a0 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -4,10 +4,12 @@ import TranslateButton from '@renderer/components/TranslateButton' import Logger from '@renderer/config/logger' import { isGenerateImageModel, + isGenerateImageModels, isSupportedDisableGenerationModel, isSupportedReasoningEffortModel, isSupportedThinkingTokenModel, isVisionModel, + isVisionModels, isWebSearchModel } from '@renderer/config/models' import db from '@renderer/databases' @@ -30,12 +32,11 @@ import WebSearchService from '@renderer/services/WebSearchService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setSearching } from '@renderer/store/runtime' import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk' -import { Assistant, FileType, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types' +import { Assistant, FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types' import type { MessageInputBaseParams } from '@renderer/types/newMessage' import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils' import { formatQuotedText } from '@renderer/utils/formats' -import { getFilesFromDropEvent } from '@renderer/utils/input' -import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input' +import { getFilesFromDropEvent, getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input' import { documentExts, imageExts, textExts } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { Button, Tooltip } from 'antd' @@ -95,17 +96,57 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const spaceClickTimer = useRef(null) const [isTranslating, setIsTranslating] = useState(false) const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState([]) - const [mentionModels, setMentionModels] = useState([]) + const [mentionedModels, setMentionedModels] = useState([]) const [isDragging, setIsDragging] = useState(false) const [isFileDragging, setIsFileDragging] = useState(false) const [textareaHeight, setTextareaHeight] = useState() const startDragY = useRef(0) const startHeight = useRef(0) const currentMessageId = useRef('') - const isVision = useMemo(() => isVisionModel(model), [model]) - const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) const { bases: knowledgeBases } = useKnowledgeBases() const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode) + const isVisionAssistant = useMemo(() => isVisionModel(model), [model]) + const isGenerateImageAssistant = useMemo(() => isGenerateImageModel(model), [model]) + + const isVisionSupported = useMemo( + () => + (mentionedModels.length > 0 && isVisionModels(mentionedModels)) || + (mentionedModels.length === 0 && isVisionAssistant), + [mentionedModels, isVisionAssistant] + ) + + const isGenerateImageSupported = useMemo( + () => + (mentionedModels.length > 0 && isGenerateImageModels(mentionedModels)) || + (mentionedModels.length === 0 && isGenerateImageAssistant), + [mentionedModels, isGenerateImageAssistant] + ) + + // 仅允许在不含图片文件时mention非视觉模型 + const couldMentionNotVisionModel = useMemo(() => { + return !files.some((file) => file.type === FileTypes.IMAGE) + }, [files]) + + // 允许在支持视觉或生成图片时添加图片文件 + const couldAddImageFile = useMemo(() => { + return isVisionSupported || isGenerateImageSupported + }, [isVisionSupported, isGenerateImageSupported]) + + const couldAddTextFile = useMemo(() => { + return isVisionSupported || (!isVisionSupported && !isGenerateImageSupported) + }, [isGenerateImageSupported, isVisionSupported]) + + const supportedExts = useMemo(() => { + if (couldAddImageFile && couldAddTextFile) { + return [...imageExts, ...documentExts, ...textExts] + } else if (couldAddImageFile) { + return [...imageExts] + } else if (couldAddTextFile) { + return [...documentExts, ...textExts] + } else { + return [] + } + }, [couldAddImageFile, couldAddTextFile]) const quickPanel = useQuickPanel() @@ -179,8 +220,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = baseUserMessage.files = uploadedFiles } - if (mentionModels) { - baseUserMessage.mentions = mentionModels + if (mentionedModels) { + baseUserMessage.mentions = mentionedModels } const assistantWithTopicPrompt = topic.prompt @@ -203,7 +244,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } catch (error) { console.error('Failed to send message:', error) } - }, [assistant, dispatch, files, inputEmpty, loading, mentionModels, resizeTextArea, text, topic]) + }, [assistant, dispatch, files, inputEmpty, loading, mentionedModels, resizeTextArea, text, topic]) const translate = useCallback(async () => { if (isTranslating) { @@ -267,7 +308,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = description: '', icon: , action: () => { - inputbarToolsRef.current?.openQuickPanel() + inputbarToolsRef.current?.openAttachmentQuickPanel() } }, ...knowledgeBases.map((base) => { @@ -378,8 +419,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } } - if (enableBackspaceDeleteModel && event.key === 'Backspace' && text.trim() === '' && mentionModels.length > 0) { - setMentionModels((prev) => prev.slice(0, -1)) + if (enableBackspaceDeleteModel && event.key === 'Backspace' && text.trim() === '' && mentionedModels.length > 0) { + setMentionedModels((prev) => prev.slice(0, -1)) return event.preventDefault() } @@ -441,36 +482,39 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const onInput = () => !expended && resizeTextArea() - const onChange = (e: React.ChangeEvent) => { - const newText = e.target.value - setText(newText) + const onChange = useCallback( + (e: React.ChangeEvent) => { + const newText = e.target.value + setText(newText) - const textArea = textareaRef.current?.resizableTextArea?.textArea - const cursorPosition = textArea?.selectionStart ?? 0 - const lastSymbol = newText[cursorPosition - 1] + const textArea = textareaRef.current?.resizableTextArea?.textArea + const cursorPosition = textArea?.selectionStart ?? 0 + const lastSymbol = newText[cursorPosition - 1] - if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') { - const quickPanelMenu = - inputbarToolsRef.current?.getQuickPanelMenu({ - t, - files, - model, - text: newText, - openSelectFileMenu, - translate - }) || [] + if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') { + const quickPanelMenu = + inputbarToolsRef.current?.getQuickPanelMenu({ + t, + files, + couldAddImageFile, + text: newText, + openSelectFileMenu, + translate + }) || [] - quickPanel.open({ - title: t('settings.quickPanel.title'), - list: quickPanelMenu, - symbol: '/' - }) - } + quickPanel.open({ + title: t('settings.quickPanel.title'), + list: quickPanelMenu, + symbol: '/' + }) + } - if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') { - inputbarToolsRef.current?.openMentionModelsPanel() - } - } + if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') { + inputbarToolsRef.current?.openMentionModelsPanel() + } + }, + [enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate] + ) const onPaste = useCallback( async (event: ClipboardEvent) => { @@ -478,7 +522,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = event, isVisionModel(model), isGenerateImageModel(model), - supportExts, + supportedExts, setFiles, setText, pasteLongTextAsFile, @@ -488,7 +532,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = t ) }, - [model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t, text] + [model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportedExts, t, text] ) const handleDragOver = (e: React.DragEvent) => { @@ -509,35 +553,38 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setIsFileDragging(false) } - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsFileDragging(false) + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsFileDragging(false) - const files = await getFilesFromDropEvent(e).catch((err) => { - Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] handleDrop:', err) - return null - }) - - if (files) { - let supportedFiles = 0 - - files.forEach((file) => { - if (supportExts.includes(getFileExtension(file.path))) { - setFiles((prevFiles) => [...prevFiles, file]) - supportedFiles++ - } + const files = await getFilesFromDropEvent(e).catch((err) => { + Logger.error('[Inputbar] handleDrop:', err) + return null }) - // 如果有文件,但都不支持 - if (files.length > 0 && supportedFiles === 0) { - window.message.info({ - key: 'file_not_supported', - content: t('chat.input.file_not_supported') + if (files) { + let supportedFiles = 0 + + files.forEach((file) => { + if (supportedExts.includes(getFileExtension(file.path))) { + setFiles((prevFiles) => [...prevFiles, file]) + supportedFiles++ + } }) + + // 如果有文件,但都不支持 + if (files.length > 0 && supportedFiles === 0) { + window.message.info({ + key: 'file_not_supported', + content: t('chat.input.file_not_supported') + }) + } } - } - } + }, + [supportedExts, t] + ) const onTranslated = (translatedText: string) => { setText(translatedText) @@ -684,7 +731,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } const handleRemoveModel = (model: Model) => { - setMentionModels(mentionModels.filter((m) => m.id !== model.id)) + setMentionedModels(mentionedModels.filter((m) => m.id !== model.id)) } const handleRemoveKnowledgeBase = (knowledgeBase: KnowledgeBase) => { @@ -715,13 +762,21 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } }, [assistant, model, updateAssistant]) - const onMentionModel = useCallback((model: Model) => { - setMentionModels((prev) => { - const modelId = getModelUniqId(model) - const exists = prev.some((m) => getModelUniqId(m) === modelId) - return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model] - }) - }, []) + const onMentionModel = useCallback( + (model: Model) => { + // 我想应该没有模型是只支持视觉而不支持文本的? + if (isVisionModel(model) || couldMentionNotVisionModel) { + setMentionedModels((prev) => { + const modelId = getModelUniqId(model) + const exists = prev.some((m) => getModelUniqId(m) === modelId) + return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model] + }) + } else { + console.error('在已上传图片时,不能添加非视觉模型') + } + }, + [couldMentionNotVisionModel] + ) const onToggleExpended = () => { const currentlyExpanded = expended || !!textareaHeight @@ -773,8 +828,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = onRemoveKnowledgeBase={handleRemoveKnowledgeBase} /> )} - {mentionModels.length > 0 && ( - + {mentionedModels.length > 0 && ( + )}