refactor(inputbar): enforce image upload and model mentioning restrictions (#7314)

* feat(inputbar): feat: enforce image upload restrictions
- allow image uploads when mentioning vision models
- disallow image uploads when non-vision models are mentioned

* refactor(Inputbar): improve handleDrop

* fix(Inputbar): Quick panel does not refresh when file changes

* fix(AttachmentButton): Fix the conditional judgment logic when mentionedModels is optional

* stash

* fix(Inputbar): Fix the issue where quickPanel does not close when files are updated

Use useRef to track changes in files, ensuring that quickPanel is properly closed when files are updated

* refactor(Inputbar): 重构附件按钮和工具条逻辑,简化文件类型支持判断

将文件类型支持判断逻辑从组件中提取到父组件,通过props传递couldAddImageFile和extensions
移除不必要的依赖和计算,优化组件性能

* fix(Inputbar): 修正文件上传逻辑并重命名快速面板方法

修复couldAddTextFile条件判断错误
将openQuickPanel重命名为openAttachmentQuickPanel以明确功能

* feat(MessageEditor): 添加基于话题ID的文件类型限制功能

根据关联消息的模型类型动态限制可添加的文件类型

* fix(MessageEditor): 仅在用户消息时显示附件按钮

根据消息角色决定是否显示附件按钮,避免非用户消息出现不必要的附件功能

* feat(MessageMenu): 添加模型筛选功能以支持视觉模型选择

根据关联消息内容动态筛选可提及的模型
当用户消息包含图片时仅显示视觉模型

* fix: 修复模型过滤器默认值处理

修复SelectModelPopup组件中modelFilter未传入时的默认值处理,使用默认值会导致卡死

* feat(输入栏): 添加模型集合功能并优化文件类型支持

添加 isVisionModels 和 isGenerateImageModels 工具函数用于判断模型集合
优化输入栏对文件类型的支持逻辑,重命名 supportExts 为 supportedExts
移除调试日志并简化模型支持判断逻辑

* refactor(Inputbar): 移除未使用的model属性并优化代码结构

清理AttachmentButton和InputbarTools组件中未使用的model属性
优化MessageEditor中的状态管理,使用useAppSelector替代store.getState
修复拼写错误(failback -> fallback)
This commit is contained in:
Wang Jiyuan 2025-07-01 12:35:02 +08:00 committed by GitHub
parent 68d0b13a64
commit f500cc6c9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 339 additions and 149 deletions

View File

@ -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<Props> = ({ model, resolve }) => {
const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
const { t } = useTranslation()
const { providers } = useProviders()
const { pinnedModels, togglePinnedModel, loading } = usePinnedModels()
@ -156,7 +158,10 @@ const PopupContainer: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ model, resolve }) => {
firstGroupRef.current = null
}
return items
}, [providers, getFilteredModels, pinnedModels, searchText, t, createModelItem])
}, [searchText.length, pinnedModels, providers, modelFilter, createModelItem, t, getFilteredModels])
// 获取可选择的模型项(过滤掉分组标题)
const modelItems = useMemo(() => {

View File

@ -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))
}

View File

@ -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<AttachmentButtonRef | null>
model: Model
couldAddImageFile: boolean
extensions: string[]
files: FileType[]
setFiles: (files: FileType[]) => void
ToolbarButton: any
disabled?: boolean
}
const AttachmentButton: FC<Props> = ({ ref, model, files, setFiles, ToolbarButton, disabled }) => {
const AttachmentButton: FC<Props> = ({
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<Props> = ({ ref, model, files, setFiles, ToolbarButto
}))
return (
<Tooltip
placement="top"
title={
isVisionModel(model) || isGenerateImageModel(model) ? t('chat.input.upload') : t('chat.input.upload.document')
}
arrow>
<Tooltip placement="top" title={couldAddImageFile ? t('chat.input.upload') : t('chat.input.upload.document')} arrow>
<ToolbarButton type="text" onClick={onSelectFile} disabled={disabled}>
<Paperclip size={18} style={{ color: files.length ? 'var(--color-primary)' : 'var(--color-icon)' }} />
</ToolbarButton>

View File

@ -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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
const [mentionModels, setMentionModels] = useState<Model[]>([])
const [mentionedModels, setMentionedModels] = useState<Model[]>([])
const [isDragging, setIsDragging] = useState(false)
const [isFileDragging, setIsFileDragging] = useState(false)
const [textareaHeight, setTextareaHeight] = useState<number>()
const startDragY = useRef<number>(0)
const startHeight = useRef<number>(0)
const currentMessageId = useRef<string>('')
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<Props> = ({ 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<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
description: '',
icon: <Upload />,
action: () => {
inputbarToolsRef.current?.openQuickPanel()
inputbarToolsRef.current?.openAttachmentQuickPanel()
}
},
...knowledgeBases.map((base) => {
@ -378,8 +419,8 @@ const Inputbar: FC<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const onInput = () => !expended && resizeTextArea()
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value
setText(newText)
const onChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
event,
isVisionModel(model),
isGenerateImageModel(model),
supportExts,
supportedExts,
setFiles,
setText,
pasteLongTextAsFile,
@ -488,7 +532,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
t
)
},
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t, text]
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportedExts, t, text]
)
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
@ -509,35 +553,38 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setIsFileDragging(false)
}
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setIsFileDragging(false)
const handleDrop = useCallback(
async (e: React.DragEvent<HTMLDivElement>) => {
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<Props> = ({ 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<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
onRemoveKnowledgeBase={handleRemoveKnowledgeBase}
/>
)}
{mentionModels.length > 0 && (
<MentionModelsInput selectedModels={mentionModels} onRemoveModel={handleRemoveModel} />
{mentionedModels.length > 0 && (
<MentionModelsInput selectedModels={mentionedModels} onRemoveModel={handleRemoveModel} />
)}
<Textarea
value={text}
@ -819,6 +874,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
assistant={assistant}
model={model}
files={files}
extensions={supportedExts}
setFiles={setFiles}
showThinkingButton={showThinkingButton}
showKnowledgeIcon={showKnowledgeIcon}
@ -826,8 +882,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
handleKnowledgeBaseSelect={handleKnowledgeBaseSelect}
setText={setText}
resizeTextArea={resizeTextArea}
mentionModels={mentionModels}
mentionModels={mentionedModels}
onMentionModel={onMentionModel}
couldMentionNotVisionModel={couldMentionNotVisionModel}
couldAddImageFile={couldAddImageFile}
onEnableGenerateImage={onEnableGenerateImage}
isExpended={isExpended}
onToggleExpended={onToggleExpended}

View File

@ -1,6 +1,6 @@
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { QuickPanelListItem } from '@renderer/components/QuickPanel'
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
import { isGenerateImageModel } from '@renderer/config/models'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types'
@ -42,21 +42,21 @@ export interface InputbarToolsRef {
getQuickPanelMenu: (params: {
t: (key: string, options?: any) => string
files: FileType[]
model: Model
couldAddImageFile: boolean
text: string
openSelectFileMenu: () => void
translate: () => void
}) => QuickPanelListItem[]
openMentionModelsPanel: () => void
openQuickPanel: () => void
openAttachmentQuickPanel: () => void
}
export interface InputbarToolsProps {
assistant: Assistant
model: Model
files: FileType[]
setFiles: (files: FileType[]) => void
extensions: string[]
showThinkingButton: boolean
showKnowledgeIcon: boolean
selectedKnowledgeBases: KnowledgeBase[]
@ -65,6 +65,8 @@ export interface InputbarToolsProps {
resizeTextArea: () => void
mentionModels: Model[]
onMentionModel: (model: Model) => void
couldMentionNotVisionModel: boolean
couldAddImageFile: boolean
onEnableGenerateImage: () => void
isExpended: boolean
onToggleExpended: () => void
@ -104,6 +106,8 @@ const InputbarTools = ({
resizeTextArea,
mentionModels,
onMentionModel,
couldMentionNotVisionModel,
couldAddImageFile,
onEnableGenerateImage,
isExpended,
onToggleExpended,
@ -111,7 +115,8 @@ const InputbarTools = ({
clearTopic,
onNewContext,
newTopicShortcut,
cleanTopicShortcut
cleanTopicShortcut,
extensions
}: InputbarToolsProps & { ref?: React.RefObject<InputbarToolsRef | null> }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
@ -153,12 +158,12 @@ const InputbarTools = ({
const getQuickPanelMenuImpl = (params: {
t: (key: string, options?: any) => string
files: FileType[]
model: Model
couldAddImageFile: boolean
text: string
openSelectFileMenu: () => void
translate: () => void
}): QuickPanelListItem[] => {
const { t, files, model, text, openSelectFileMenu, translate } = params
const { t, files, couldAddImageFile, text, openSelectFileMenu, translate } = params
return [
{
@ -226,7 +231,7 @@ const InputbarTools = ({
}
},
{
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
label: couldAddImageFile ? t('chat.input.upload') : t('chat.input.upload.document'),
description: '',
icon: <Paperclip />,
isMenu: true,
@ -276,7 +281,7 @@ const InputbarTools = ({
useImperativeHandle(ref, () => ({
getQuickPanelMenu: getQuickPanelMenuImpl,
openMentionModelsPanel: () => mentionModelsButtonRef.current?.openQuickPanel(),
openQuickPanel: () => attachmentButtonRef.current?.openQuickPanel()
openAttachmentQuickPanel: () => attachmentButtonRef.current?.openQuickPanel()
}))
const toolButtons = useMemo<ToolButtonConfig[]>(() => {
@ -298,7 +303,8 @@ const InputbarTools = ({
component: (
<AttachmentButton
ref={attachmentButtonRef}
model={model}
couldAddImageFile={couldAddImageFile}
extensions={extensions}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
@ -364,9 +370,11 @@ const InputbarTools = ({
component: (
<MentionModelsButton
ref={mentionModelsButtonRef}
mentionModels={mentionModels}
mentionedModels={mentionModels}
onMentionModel={onMentionModel}
ToolbarButton={ToolbarButton}
couldMentionNotVisionModel={couldMentionNotVisionModel}
files={files}
/>
)
},
@ -416,6 +424,9 @@ const InputbarTools = ({
assistant,
cleanTopicShortcut,
clearTopic,
couldAddImageFile,
couldMentionNotVisionModel,
extensions,
files,
handleKnowledgeBaseSelect,
isExpended,

View File

@ -1,16 +1,16 @@
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import { useQuickPanel } from '@renderer/components/QuickPanel'
import { QuickPanelListItem } from '@renderer/components/QuickPanel/types'
import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { getModelLogo, isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { FileType, Model } from '@renderer/types'
import { Avatar, Tooltip } from 'antd'
import { useLiveQuery } from 'dexie-react-hooks'
import { first, sortBy } from 'lodash'
import { AtSign, Plus } from 'lucide-react'
import { FC, memo, useCallback, useImperativeHandle, useMemo } from 'react'
import { FC, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import styled from 'styled-components'
@ -21,12 +21,21 @@ export interface MentionModelsButtonRef {
interface Props {
ref?: React.RefObject<MentionModelsButtonRef | null>
mentionModels: Model[]
mentionedModels: Model[]
onMentionModel: (model: Model) => void
couldMentionNotVisionModel: boolean
files: FileType[]
ToolbarButton: any
}
const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, ToolbarButton }) => {
const MentionModelsButton: FC<Props> = ({
ref,
mentionedModels,
onMentionModel,
couldMentionNotVisionModel,
files,
ToolbarButton
}) => {
const { providers } = useProviders()
const { t } = useTranslation()
const navigate = useNavigate()
@ -49,6 +58,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
p.models
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
.filter((m) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(m)))
.map((m) => ({
label: (
<>
@ -64,7 +74,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
),
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
action: () => onMentionModel(m),
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
}))
)
@ -77,7 +87,8 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
const providerModels = sortBy(
p.models
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
.filter((m) => !pinnedModels.includes(getModelUniqId(m))),
.filter((m) => !pinnedModels.includes(getModelUniqId(m)))
.filter((m) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(m))),
['group', 'name']
)
@ -96,7 +107,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
),
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
action: () => onMentionModel(m),
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
}))
if (providerModelItems.length > 0) {
@ -112,7 +123,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
})
return items
}, [providers, t, pinnedModels, mentionModels, onMentionModel, navigate])
}, [pinnedModels, providers, t, couldMentionNotVisionModel, mentionedModels, onMentionModel, navigate])
const openQuickPanel = useCallback(() => {
quickPanel.open({
@ -134,6 +145,18 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
}
}, [openQuickPanel, quickPanel])
const filesRef = useRef(files)
useEffect(() => {
// 检查files是否变化
if (filesRef.current !== files) {
if (quickPanel.isVisible && quickPanel.symbol === '@') {
quickPanel.close()
}
filesRef.current = files
}
}, [files, quickPanel])
useImperativeHandle(ref, () => ({
openQuickPanel
}))

View File

@ -147,6 +147,7 @@ const MessageItem: FC<Props> = ({
{isEditing && (
<MessageEditor
message={message}
topicId={topic.id}
onSave={handleEditSave}
onResend={handleEditResend}
onCancel={handleEditCancel}

View File

@ -5,6 +5,8 @@ import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import FileManager from '@renderer/services/FileManager'
import PasteService from '@renderer/services/PasteService'
import { useAppSelector } from '@renderer/store'
import { selectMessagesForTopic } from '@renderer/store/newMessage'
import { FileType, FileTypes } from '@renderer/types'
import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { classNames, getFileExtension } from '@renderer/utils'
@ -25,12 +27,13 @@ import { ToolbarButton } from '../Inputbar/Inputbar'
interface Props {
message: Message
topicId: string
onSave: (blocks: MessageBlock[]) => void
onResend: (blocks: MessageBlock[]) => void
onCancel: () => void
}
const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel }) => {
const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onCancel }) => {
const allBlocks = findAllBlocks(message)
const [editedBlocks, setEditedBlocks] = useState<MessageBlock[]>(allBlocks)
const [files, setFiles] = useState<FileType[]>([])
@ -44,6 +47,7 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
const { t } = useTranslation()
const textareaRef = useRef<TextAreaRef>(null)
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
const isUserMessage = message.role === 'user'
const resizeTextArea = useCallback(() => {
const textArea = textareaRef.current?.resizableTextArea?.textArea
@ -205,6 +209,52 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
}
}
const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, topicId))
const couldAddImageFile = useMemo(() => {
const relatedAssistantMessages = topicMessages.filter((m) => m.askId === message.id && m.role === 'assistant')
if (relatedAssistantMessages.length === 0) {
// 无关联消息时fallback到助手模型
return isVisionModel(model)
}
return relatedAssistantMessages.every((m) => {
if (m.model) {
return isVisionModel(m.model)
} else {
// 若消息关联不存在的模型,视为其支持视觉
return true
}
})
}, [message.id, model, topicMessages])
const couldAddTextFile = useMemo(() => {
const relatedAssistantMessages = topicMessages.filter((m) => m.askId === message.id && m.role === 'assistant')
if (relatedAssistantMessages.length === 0) {
// 无关联消息时fallback到助手模型
return isVisionModel(model) || (!isVisionModel(model) && !isGenerateImageModel(model))
}
return relatedAssistantMessages.every((m) => {
if (m.model) {
return isVisionModel(m.model) || (!isVisionModel(m.model) && !isGenerateImageModel(m.model))
} else {
// 若消息关联不存在的模型,视为其支持文本
return true
}
})
}, [message.id, model, topicMessages])
const extensions = useMemo(() => {
if (couldAddImageFile && couldAddTextFile) {
return [...imageExts, ...documentExts, ...textExts]
} else if (couldAddImageFile) {
return [...imageExts]
} else if (couldAddTextFile) {
return [...documentExts, ...textExts]
} else {
return []
}
}, [couldAddImageFile, couldAddTextFile])
return (
<EditorContainer className="message-editor" onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
{editedBlocks
@ -273,13 +323,16 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
<ActionBar>
<ActionBarLeft>
<AttachmentButton
ref={attachmentButtonRef}
model={model}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
/>
{isUserMessage && (
<AttachmentButton
ref={attachmentButtonRef}
files={files}
setFiles={setFiles}
couldAddImageFile={couldAddImageFile}
extensions={extensions}
ToolbarButton={ToolbarButton}
/>
)}
</ActionBarLeft>
<ActionBarMiddle />
<ActionBarRight>

View File

@ -1,6 +1,7 @@
import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import { isVisionModel } from '@renderer/config/models'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
import { useChatContext } from '@renderer/hooks/useChatContext'
@ -11,9 +12,9 @@ import { getMessageTitle } from '@renderer/services/MessagesService'
import { translateText } from '@renderer/services/TranslateService'
import store, { RootState } from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import type { Model } from '@renderer/types'
import type { Assistant, Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { selectMessagesForTopic } from '@renderer/store/newMessage'
import type { Assistant, Model, Topic } from '@renderer/types'
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL } from '@renderer/utils'
import { copyMessageAsPlainText } from '@renderer/utils/copy'
import {
@ -29,8 +30,20 @@ import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown'
import { findMainTextBlocks, findTranslationBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { Dropdown, Popconfirm, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { AtSign, Copy, Languages, ListChecks, Menu, RefreshCw, Save, Share, Split, ThumbsUp, Trash } from 'lucide-react'
import { FilePenLine } from 'lucide-react'
import {
AtSign,
Copy,
FilePenLine,
Languages,
ListChecks,
Menu,
RefreshCw,
Save,
Share,
Split,
ThumbsUp,
Trash
} from 'lucide-react'
import { FC, memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
@ -327,13 +340,43 @@ const MessageMenubar: FC<Props> = (props) => {
regenerateAssistantMessage(message, assistantWithTopicPrompt)
}
const onMentionModel = async (e: React.MouseEvent) => {
e.stopPropagation()
if (loading) return
const selectedModel = await SelectModelPopup.show({ model })
if (!selectedModel) return
appendAssistantResponse(message, selectedModel, { ...assistant, model: selectedModel })
}
// 按条件筛选能够提及的模型该函数仅在isAssistantMessage时会用到
const mentionModelFilter = useMemo(() => {
if (!isAssistantMessage) {
return () => true
}
const state = store.getState()
const topicMessages = selectMessagesForTopic(state, topic.id)
// 理论上助手消息只会关联一条用户消息
const relatedUserMessage = topicMessages.find((msg) => {
return msg.role === 'user' && message.askId === msg.id
})
// 无关联用户消息时,默认返回所有模型
if (!relatedUserMessage) {
return () => true
}
const relatedUserMessageBlocks = relatedUserMessage.blocks.map((msgBlockId) =>
messageBlocksSelectors.selectById(store.getState(), msgBlockId)
)
if (relatedUserMessageBlocks.some((block) => block.type === MessageBlockType.IMAGE)) {
return (m: Model) => isVisionModel(m)
} else {
return () => true
}
}, [isAssistantMessage, message.askId, topic.id])
const onMentionModel = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation()
if (loading) return
const selectedModel = await SelectModelPopup.show({ model, modelFilter: mentionModelFilter })
if (!selectedModel) return
appendAssistantResponse(message, selectedModel, { ...assistant, model: selectedModel })
},
[appendAssistantResponse, assistant, loading, mentionModelFilter, message, model]
)
const onUseful = useCallback(
(e: React.MouseEvent) => {

View File

@ -17,8 +17,7 @@ import type {
PlaceholderMessageBlock,
ToolMessageBlock
} from '@renderer/types/newMessage'
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { Response } from '@renderer/types/newMessage'
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType, Response } from '@renderer/types/newMessage'
import { uuid } from '@renderer/utils'
import { formatErrorMessage, isAbortError } from '@renderer/utils/error'
import {